ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android屏幕适配:DisplayMetrics工具类封装与系统UI占位处理

Android屏幕适配:DisplayMetrics工具类封装与系统UI占位处理 做 Android 开发这么多年屏幕宽高和密度这几个数值几乎是天天要碰的东西。加载图片要按屏幕宽度算比例自定义 View 要手动把 dp 转成 px做适配要判断当前设备的 densityDpi就连写 UI 自动化用例也绕不开 DisplayMetrics。但很多项目里拿宽高的代码散落在各个 Activity 里时而用getResources().getDisplayMetrics()时而从windowManager拿糊里糊涂地 copy 来 copy 去。我这次把一个实际项目里的屏幕信息获取逻辑整理成了一个工具类把宽高、密度、可用高度、状态栏导航栏高度这些数据统一收口顺带解决了不少版本适配和坑。这篇就把完整实现、每个方法背后的原理以及我在真机测试时踩过的坑一并写出来给正在做屏幕适配或者需要精准获取设备尺寸的同行一个可参考的方案。这个工具类解决的是三类问题。一是拿到可靠的物理像素宽高用于图片裁剪、横竖屏判断、弹窗定位二是做 dp、px、sp 之间的换算避免到处写(int) (dp * density 0.5f)三是拿“真正能用的高度”也就是排除状态栏、导航栏、挖孔区域之后的应用区域。适合刚开始接触适配的新人也适合那些已经写了三五年业务代码、但没时间系统整理这些逻辑的开发者参考。1. 为什么不直接写“屏幕宽高”而要自己封装1.1 三行代码能拿宽高为什么还要写个类很多同学会问DisplayMetrics里不是直接有widthPixels和heightPixels吗随手写三行不就完事了val metrics resources.displayMetrics val width metrics.widthPixels val height metrics.heightPixels对设备分辨率确实就这样拿到了。但这个做法在真实业务里撑不住原因有三个。第一它不是语义化的。heightPixels到底是整个屏幕的高度还是当前窗口的高度取决于你从哪里拿 DisplayMetrics。如果在一个带全屏主题的 Activity 里和在普通主题的 Activity 里拿到的结果可能不一样。线上很多诡异的 UI bug根源就是有人在不同地方拿到的“屏幕高度”含义不同却拿去做同样的计算。第二它缺少换算能力。产品给的 UI 图是 dp 单位控件尺寸上标的是密度无关像素但你从widthPixels拿到的是 px。你总不能在每处使用到的地方都写一遍TypedValue.applyDimension或者手写乘法。第三它没有处理系统 UI 占位。手机顶部有状态栏底部有导航栏全面屏还有挖孔区域。真正可用的布局高度应该是屏幕总高减去这些装饰区域之后的值。这个“可用高度”在不同机型、不同系统版本上的计算方式都有差异不封装就必然要重复踩坑。1.2 我现在项目里遇到的真实问题这次整理工具类的直接起因是项目里一个卡片轮播效果在部分机型上底部被导航栏挡住。排查时发现负责轮播图高度计算的同事直接用了DisplayMetrics.heightPixels做比例换算没有扣除底部导航栏高度。小米、华为的虚拟导航栏普遍存在iPhone 式的手势条在 Android 10 以后也越来越常见这个问题不是个例。另一个问题是横竖屏切换。App 支持横屏后每次旋转都会触发 Activity 重建如果用静态变量缓存了屏幕宽高旋转之后拿到的是旧值布局直接崩。这些问题让我意识到屏幕信息获取必须做成一个动态计算、可复用的工具类而不是到处 new 一个 Metrics 出来用。2. 宽高和屏幕密度这几个数值到底在内存里经历了什么2.1 px、dp、dpi、density 的换算关系在写工具类之前得先把这几个概念彻底捋清楚否则后面看代码还是会一头雾水。px物理像素点。设备屏幕上有多少个发光的点。dp也叫 dip密度无关像素。为了方便适配引入的虚拟单位。dpi屏幕每英寸的像素点数官方叫densityDpi常见取值 120、160、240、320、480 等。densitydp 和 px 的换算系数。它的计算公式是dpi / 160。拿一个经典的例子来说。同样一个100dp的按钮在 160dpi 的屏幕上就是 100px在 320dpi 的屏幕上就是 200px。物理尺寸上看起来差不多大但在像素层面差了一倍。所谓“屏幕密度”就是描述这种物理尺寸和像素数量之间关系的参数。换算公式就是三个px dp * density dp px / density sp px / scaledDensity其中scaledDensity和density的区别在于前者跟随用户在系统设置里调整的字体大小。所以字体相关换算一定用scaledDensity布局间距用density就够。2.2 状态栏、导航栏、Cutout 对可用宽高的影响这是封装工具类时最容易忽略的部分。你可能以为DisplayMetrics.heightPixels就是屏幕高度其实在大多数情况下它返回的是整个屏幕的物理像素高度并不会帮你剔除状态栏和导航栏。但从业务角度说我们布局真正能用的区域是屏幕总高减掉系统装饰区之后剩下的部分。具体来说状态栏高度可以通过资源 ID 拿到Suppress(DEPRECATION) fun getStatusBarHeight(context: Context): Int { var result 0 val resourceId context.resources.getIdentifier(status_bar_height, dimen, android) if (resourceId 0) { result context.resources.getDimensionPixelSize(resourceId) } return result }导航栏高度的获取稍微讲究一点因为从 Android 4.4 开始支持透明导航栏从 Android 10 开始又全面转向手势导航手势导航下导航栏的高度定义和传统三键模式不同。最稳妥的方式是直接测量应用窗口和整个屏幕之间的差值也就是用getRealMetrics拿到物理屏幕尺寸再和getMetrics拿到的应用可用区域做差。这个差值就是底部被系统 UI 占用的高度不管它是三键导航还是手势条都能正确反映出来。挖孔屏的处理更麻烦。不同的挖孔位置左上角、居中、胶囊形影响的是刘海区域的systemWindowInset值。工具类里可以提供一个getCutoutHeight方法在 API 28 及以上读取DisplayCutout.getSafeInsetTop()。需要注意这个值只有在窗口layoutInDisplayCutoutMode设置为SHORT_EDGES或ALWAYS时才是非零默认模式下窗口不会延伸到挖孔区域也就拿不到安全区的 API 返回值。2.3 多窗口和分屏下的宽高变化提到屏幕宽高还不能回避一个场景分屏。Android 7.0 开始支持真正的多窗口用户把屏幕一分为二之后你的 Activity 拿到的“窗口尺寸”就变了。这时候如果你还在用Display.getRealMetrics去拿物理屏幕大小得到的值就会偏大布局会超出窗口范围。工具类在设计上要区分两种概念概念获取方式使用场景物理屏幕宽高WindowManager.getDefaultDisplay().getRealMetrics()计算屏幕物理比例、截图、系统级场景应用窗口宽高activity.windowManager.defaultDisplay.getMetrics()或WindowMetrics正常的业务布局、分屏适配业务代码里绝大多数情况下应该用“应用窗口宽高”而不是物理宽高。封装时要把两种都公开出来让调用方按场景选择而不是一刀切只给一个值。3. 工具类的完整实现与关键方法拆解3.1 从 Activity 还是 Context 获取信息这个选择直接决定工具类的可靠性。如果只用Context.getResources().getDisplayMetrics()在某些经过多窗口、画中画、Display 切换的场景下拿到的可能是默认 Display 的指标而不是当前 Activity 实际所在的窗口指标。因此建议工具类的入口尽量接收Activity而不是裸Context。只有当你确定应用没有多窗口需求时才退化为用Context获取。我最终的实现是对外暴露的方法必须传Activity内部用activity.windowManager来拿窗口信息这样能保证和当前可见窗口一致。3.2 屏幕宽高的几种获取方式对比这地方我踩过坑值得展开说明。DisplayMetrics可以通过三条路径拿到// 方式一从 Context 拿资源 val metrics1 context.resources.displayMetrics // 方式二从 WindowManager 拿默认 Display val wm context.getSystemService(Context.WINDOW_SERVICE) as WindowManager val metrics2 DisplayMetrics() if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { val windowMetrics wm.currentWindowMetrics val bounds windowMetrics.bounds metrics2.widthPixels bounds.width() metrics2.heightPixels bounds.height() } else { Suppress(DEPRECATION) wm.defaultDisplay.getMetrics(metrics2) } // 方式三从 Activity 的 Window 拿 val metrics3 DisplayMetrics() activity.windowManager.defaultDisplay.getMetrics(metrics3)三条路径的差异很微妙。方式一在大多数情况下可用但如果你把context传入的是一个非 Activity 的 Context比如applicationContext那么resources.displayMetrics拿到的只是系统默认值不会跟随当前窗口变化。方式二在 API 30 以后有标准化的WindowMetrics接口但defaultDisplay.getMetrics()本身已经被标记废弃。方式三结合 Activity 使用最可靠也是我封装时首选的主路径。3.3 工具类代码DeviceScreenInfo下面给出一个我在项目里打磨过的工具类。完整实现了宽高、密度、换算、系统 UI 占位这几个维度的信息获取。为了节省篇幅我用 Kotlin 写主版本关键方法附注释。import android.app.Activity import android.content.Context import android.content.res.Resources import android.graphics.Point import android.os.Build import android.util.DisplayMetrics import android.util.TypedValue import android.view.WindowInsets import android.view.WindowManager /** * 设备屏幕信息工具类 * 统一获取屏幕宽高、密度、系统 UI 占位等信息 * * 设计原则 * 1. 所有要求当前窗口的方法都接收 Activity避免使用 applicationContext * 2. 不缓存宽高数值因为旋转、分屏、折叠屏都会导致屏幕尺寸变化 * 3. 同时提供物理屏幕和应用窗口两套尺寸业务默认用窗口尺寸 */ object DeviceScreenInfo { /** * 获取系统默认 DisplayMetrics注意不代表当前窗口仅做兜底 */ private fun getSystemDisplayMetrics(context: Context): DisplayMetrics { return context.resources.displayMetrics } /** * 获取当前窗口的 DisplayMetrics推荐 */ private fun getWindowDisplayMetrics(activity: Activity): DisplayMetrics { val metrics DisplayMetrics() val windowManager activity.windowManager if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { // API 30 使用 WindowMetrics 获取窗口边界 val bounds windowManager.currentWindowMetrics.bounds metrics.widthPixels bounds.width() metrics.heightPixels bounds.height() } else { Suppress(DEPRECATION) windowManager.defaultDisplay.getMetrics(metrics) } return metrics } /** * 获取物理屏幕的 DisplayMetrics包含系统装饰区 */ private fun getRealDisplayMetrics(activity: Activity): DisplayMetrics { val metrics DisplayMetrics() if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { val windowManager activity.windowManager val bounds windowManager.maximumWindowMetrics.bounds metrics.widthPixels bounds.width() metrics.heightPixels bounds.height() } else { Suppress(DEPRECATION) activity.windowManager.defaultDisplay.getRealMetrics(metrics) } return metrics } /** * 获取当前窗口宽度px */ fun getWindowWidth(activity: Activity): Int { return getWindowDisplayMetrics(activity).widthPixels } /** * 获取当前窗口高度px */ fun getWindowHeight(activity: Activity): Int { return getWindowDisplayMetrics(activity).heightPixels } /** * 获取当前窗口宽度dp */ fun getWindowWidthDp(activity: Activity): Int { return px2dp(activity, getWindowWidth(activity).toFloat()).toInt() } /** * 获取当前窗口高度dp */ fun getWindowHeightDp(activity: Activity): Int { return px2dp(activity, getWindowHeight(activity).toFloat()).toInt() } /** * 获取物理屏幕宽度px */ fun getScreenWidth(activity: Activity): Int { return getRealDisplayMetrics(activity).widthPixels } /** * 获取物理屏幕高度px */ fun getScreenHeight(activity: Activity): Int { return getRealDisplayMetrics(activity).heightPixels } /** * 获取屏幕密度 density即 dp 到 px 的换算系数 */ fun getDensity(activity: Activity): Float { return getWindowDisplayMetrics(activity).density } /** * 获取屏幕 dpi */ fun getDensityDpi(activity: Activity): Int { return getWindowDisplayMetrics(activity).densityDpi } /** * 获取字体缩放密度 scaledDensity */ fun getScaledDensity(activity: Activity): Float { return getWindowDisplayMetrics(activity).scaledDensity } /** * 获取当前屏幕的 dpi 档位名称便于日志输出和适配判断 */ fun getDpiLevel(activity: Activity): String { return when (getDensityDpi(activity)) { DisplayMetrics.DENSITY_LOW - ldpi DisplayMetrics.DENSITY_MEDIUM - mdpi DisplayMetrics.DENSITY_HIGH - hdpi DisplayMetrics.DENSITY_XHIGH - xhdpi DisplayMetrics.DENSITY_XXHIGH - xxhdpi DisplayMetrics.DENSITY_XXXHIGH - xxxhdpi else - unknown } } /** * dp 转 px */ fun dp2px(activity: Activity, dp: Float): Float { return dp * getDensity(activity) } /** * px 转 dp */ fun px2dp(activity: Activity, px: Float): Float { return px / getDensity(activity) } /** * sp 转 px字体 */ fun sp2px(activity: Activity, sp: Float): Float { return sp * getScaledDensity(activity) } /** * px 转 sp字体 */ fun px2sp(activity: Activity, px: Float): Float { return px / getScaledDensity(activity) } /** * 获取状态栏高度px */ Suppress(DEPRECATION) fun getStatusBarHeight(context: Context): Int { var result 0 val resourceId context.resources.getIdentifier( status_bar_height, dimen, android ) if (resourceId 0) { result context.resources.getDimensionPixelSize(resourceId) } return result } /** * 获取底部系统导航栏高度px * 通过物理屏幕高 - 系统可用窗口高计算能兼容三键导航和手势导航 */ fun getNavigationBarHeight(activity: Activity): Int { val screenHeight getScreenHeight(activity) val windowHeight getWindowHeight(activity) // 注意如果 Activity 是沉浸式全屏导航栏一般是被隐藏的此时差值可能为 0 return (screenHeight - windowHeight).coerceAtLeast(0) } /** * 获取 App 实际可用高度px即窗口高度 */ fun getAvailableHeight(activity: Activity): Int { return getWindowHeight(activity) } /** * 获取 Cutout挖孔/刘海安全区域的顶部高度 */ fun getCutoutSafeInsetTop(activity: Activity): Int { if (Build.VERSION.SDK_INT Build.VERSION_CODES.P) { return 0 } val windowInsets activity.window.decorView.rootWindowInsets ?: return 0 val cutout windowInsets.displayCutout ?: return 0 return cutout.safeInsetTop } /** * 获取系统 UI 顶部总占位状态栏 刘海安全区 */ fun getSystemUiTopHeight(activity: Activity): Int { return getStatusBarHeight(activity) getCutoutSafeInsetTop(activity) } /** * 遍历输出当前设备屏幕信息方便定位适配问题 */ fun logScreenInfo(activity: Activity) { val sb StringBuilder() sb.append(window size: ) .append(getWindowWidth(activity)).append( x ) .append(getWindowHeight(activity)).append( px\n) sb.append(screen size: ) .append(getScreenWidth(activity)).append( x ) .append(getScreenHeight(activity)).append( px\n) sb.append(density: ).append(getDensity(activity)) .append(, dpi: ).append(getDensityDpi(activity)) .append(().append(getDpiLevel(activity)).append()\n) sb.append(statusBarHeight: ).append(getStatusBarHeight(activity)).append( px\n) sb.append(navBarHeight: ).append(getNavigationBarHeight(activity)).append( px\n) sb.append(scaledDensity: ).append(getScaledDensity(activity)).append(\n) android.util.Log.d(DeviceScreenInfo, sb.toString()) } }3.4 方法逐个说明getWindowDisplayMetrics和getRealDisplayMetrics是内部的底座方法其他方法都建立在它们之上。API 30 以上的处理方式有两个关键点一是currentWindowMetrics返回的是应用窗口的边界二是maximumWindowMetrics返回的是整个屏幕可用区域中最大的边界。后者用来替代getRealMetrics但两者的语义不完全等价在某些多窗口场景下maximumWindowMetrics会返回整个屏幕的边界具体表现取决于系统如何定义“最大窗口”。dp2px、px2dp这类换算方法看起来简单但有个细节值得注意调用时每次都通过getDensity(activity)去拿最新的 density。为什么不直接缓存到一个变量里因为不同 Display 的 density 可能不同你的 Activity 可能会因为切换 Display 而被重建重建后的 Activity 拿到的 density 才是当前 Display 的真实密度。缓存反而可能拿旧值。getNavigationBarHeight用“物理屏幕高 - 窗口高”的算法是权衡之后的选择。它有一个前提当前 Activity 不是沉浸式全屏。如果窗口已经全屏、导航栏隐藏这个差值天然就是 0反而符合实际因为导航栏已经不存在了。如果窗口是半透明、状态栏沉浸但导航栏仍然占位的模式窗口高度和物理高度之间的差值基本就等于导航栏高度。真机测试下来这个算法比去反射内部R.dimen.navigation_bar_height稳得多。getSystemUiTopHeight是给“窗口需要避开系统 UI”的场景用的。结合WindowInsets使用也可以但工具类选择简化只提供资源 ID Cutout 安全区的组合值够用且不易出错。4. 真实项目里的使用场景与代码示例4.1 图片加载时按屏幕宽度等比例裁剪图片 Banner 是很经典的使用场景。设计稿上的 Banner 宽度就是屏幕宽度高度按照 2:1 比例计算。在没有依赖第三方图片加载库的裁剪功能时可以先用工具类把目标尺寸算出来再交给加载库做 Resize。class BannerAdapter(private val activity: Activity) : RecyclerView.AdapterBannerViewHolder() { private val bannerWidth: Int private val bannerHeight: Int init { bannerWidth DeviceScreenInfo.getWindowWidth(activity) bannerHeight (bannerWidth * 0.5f).toInt() } override fun onBindViewHolder(holder: BannerViewHolder, position: Int) { // 例如 Glide 加载固定尺寸 Glide.with(holder.itemView) .load(imageUrl) .override(bannerWidth, bannerHeight) .centerCrop() .into(holder.imageView) } }这里必须注意bannerWidth和bannerHeight不能定义成伴生对象的静态常量否则横竖屏切换后 Activity 重建Adapter 不会重新计算图片尺寸就错了。把尺寸计算放在 Adapter 实例初始化的时机里随 Activity 一起重建这是最简单也最稳的做法。4.2 自定义 View 里根据 density 换算自定义 View 是 dp 转 px 最常见的重灾区。很多人习惯在onDraw里写死16 * density而 density 是从哪拿的完全看当时手边有什么变量。这样写的隐患是同一个 View 如果被用在不同的 Display 上而 View 又持有缓存的 density 值就会算错。更好的方式是在onMeasure或init时通过工具类动态获取class ProgressTextView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : View(context, attrs, defStyleAttr) { private val density: Float private val textSpSize 14f private val paddingDp 8f init { val activity context.findActivity() density if (activity ! null) { DeviceScreenInfo.getDensity(activity) } else { context.resources.displayMetrics.density } } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val textPaint Paint(Paint.ANTI_ALIAS_FLAG).apply { textSize DeviceScreenInfo.sp2px(requireActivity(), textSpSize) color Color.WHITE } // 使用 paddingDp * density 计算 padding val paddingPx paddingDp * density // draw something } private fun Context.findActivity(): Activity? { return when (this) { is Activity - this is ContextWrapper - baseContext.findActivity() else - null } } }有一点要坦诚说Context.findActivity()这种写法不是百分之百可靠在有些 View 被跨进程或者通过ContextThemeWrapper多层包装时会失效。所以工具类里所有方法都接收Activity而不是在内部做 Context 的类型强转。View 里如果拿不到 Activity就退化为context.resources.displayMetrics至少兜底值不会崩。4.3 UI 自动化测试中使用写 Espresso 或 UIAutomator 用例时经常需要根据屏幕尺寸计算点击坐标。这时候工具类可以直接在测试代码里复用避免在测试里再写一套获取屏幕尺寸的逻辑。class ScreenInfoTest { Test fun testClickBottomButton() { val activityScenario ActivityScenario.launch(MainActivity::class.java) activityScenario.onActivity { activity - val screenWidth DeviceScreenInfo.getWindowWidth(activity) val screenHeight DeviceScreenInfo.getWindowHeight(activity) // 点击右下角某个相对位置 val x screenWidth * 0.9f val y screenHeight * 0.9f // 结合 UiDevice.click 或 ViewActions.click 使用 } } }在测试中复用工具类还有一个好处当设备是多窗口模式时getWindowHeight拿到的就是当前窗口的高度测试坐标不会跑偏。如果用getScreenHeight拿物理高度就会在分屏模式下点错位置。5. 多设备适配与测试时的实际操作5.1 不同 dpi 档位的数值对照表做适配前先要清楚当前设备落在哪个档位。下面这个表是 Android 官方对常见档位的定义注意同是“xxhdpi”不同厂商的densityDpi也可能是 440 这种中间值而不是标准的 480。档位densityDpidensity典型设备/分辨率ldpi1200.75老旧低端机mdpi1601.0早期设备基准hdpi2401.5720p 时代主流xhdpi3202.01080p 主流xxhdpi4803.01440p 主流xxxhdpi6404.04K 旗舰机为了验证工具类输出的数据你可以直接调用logScreenInfo(activity)它会打出一条包含所有关键指标的结构化日志。我在适配阶段通常会在测试页面放一个 Debug 入口点一下就把日志导出对照上面的表格确认设备落在哪一档再决定切哪套资源目录。5.2 使用模拟器和真机验证的步骤工具类写完不能只在开发机上跑要多设备验证。我的标准验证流程是这样。先准备一组测试设备至少覆盖一款 1080p 三键导航的旧机型、一款 1440p 手势导航的新旗舰、一款带刘海或挖孔的全面屏、一款支持分屏的平板或大屏设备。没有真机就用模拟器补模拟器可以自由调整 dpi 和屏幕分辨率适合验证特殊档位。然后在MainActivity的onCreate或onResume调用工具类方法把宽高、density、状态栏导航栏高度全部打日志。逐个旋转屏幕、进出分屏观察日志数值是否随之变化。重点看三件事getWindowWidth在横竖屏切换后是否正确交换getNavigationBarHeight在三键模式和手势模式下是否保持合理getCutoutSafeInsetTop在挖孔屏上是否非零在非挖孔屏上是否为零。我这次整理工具类时就在一台华为折叠屏上发现展开态和折叠态的densityDpi可能不同导致dp2px换算结果不一样。这种机型如果用了静态缓存分分钟出问题。5.3 旋转、分屏、折叠屏的异常情况旋转是比较容易处理的因为 Activity 默认会重建工具类的新对象天然会拿到新值。但分屏和折叠屏比旋转更隐蔽。分屏时当前窗口高度直接减半但getScreenHeight拿到的物理屏高不变。如果某个业务逻辑在计算底部弹窗位置时误用了物理屏高弹窗就会超出窗口范围。这里有一个非常实用的判断如果getWindowHeight(activity) getScreenHeight(activity)说明当前窗口可能不是全屏状态。可以在日志和上报逻辑里加入这个判断快速定位分屏相关的适配问题。折叠屏的问题更复杂。展开态和折叠态之间不仅尺寸变了densityDpi也可能变化。Android 12L 开始有专门的多窗口 APIs但很多应用还没有适配。我的建议是工具类不要做任何形态判断只做好一件事每次调用都返回当前时刻的真实值。业务方根据拿到的值自行判断是否需要重新测量。6. 封装这个工具类时最容易踩的几个坑6.1 不要在 Application 里提前拿宽高这是我在代码 review 里看到最多的问题。有些方案为了“性能优化”在 Application 的onCreate里就初始化一个单例把屏幕宽高缓存起来以为这样能少走几次 IPC。想法可以理解但行为是错误的。Application 在启动时拿到的 DisplayMetrics 并不一定等于用户最终看到的窗口尺寸。一方面部分应用支持跨屏启动App 会在某个 Display 上启动但 Application 初始化时还没有绑定到具体 Display另一方面分屏和折叠屏状态下屏幕尺寸是可变的启动时缓存的值很快就过期了。屏幕宽高的获取成本极低就是一个属性读操作根本不需要缓存。真要是性能担忧问题也不在这里。所以工具类的设计原则是不缓存每次现拿。6.2 WindowManager 与 Display 的版本差异Display.getRealMetrics在 API 30 被废弃Display.getMetrics(DisplayMetrics)在 API 31 被废弃。看起来 API 30 以后应该统一用WindowMetrics实际项目中没那么简单。currentWindowMetrics拿到的窗口边界会包含某些情况下系统窗口区域比如多窗口下窗口的圆角裁剪。在部分 ROM 上它的返回值和你从decorView.rootWindowInsets推导出的可用区域并不完全一致。因此工具类里API 30 以上的getWindowHeight并不能保证在所有机型和所有窗口模式下都和实际布局高度一致必要时需要结合WindowInsets做二次修正。兼容性处理的通用准则优先使用WindowMetrics拿到窗口边界遇到极端场景再降级到WindowInsets计算。如果你还在支持 minSdk 比较低的老项目defaultDisplay.getMetrics必须加Suppress(DEPRECATION)但不要直接删掉因为低版本没有替代 API。6.3 拿到的是“物理像素”还是“逻辑像素”很多 android 开发者会把DisplayMetrics里的值当成“逻辑像素”其实它里面存的一律是物理像素没有经过系统 UI 缩放。Android 没有像 iOS 那样区分 point 和 pixeldp的作用就是代替“逻辑像素”这个概念。这个混淆会导致一个经典 bug设计师标注一个按钮宽 320dp开发者把dp2px的结果拿去设 width但dp2px要乘的是 density如果设备是 440dpidensity 是 2.75320dp 转出来就是 880px在 1080p 屏幕上会溢出。这个问题的根源是把“设计标注值”误当成了像素值。工具类把dp2px和px2dp都封好并不能防止这类错误但至少能让换算调用更规范。开发者只要记住从 UI 图拿到的数字除非特别注明是 px否则一律当成 dp先经过dp2px再使用。6.4 单例缓存与页面销毁的平衡有人说对象写单例就是缓存。工具类作为object没问题因为它内部没有保存任何和屏幕尺寸相关的状态所有方法体都是临时的局部变量。但如果有人往里面加了一个var screenWidth: Int并对外暴露那就破坏了平衡。一组比较安全的实践是工具类内部只放常量配置和纯函数一切可变状态由调用方持有。如果某个页面确实需要缓存宽高来减少重复计算缓存的责任应该放在 Page 或 ViewModel 的私有属性里而不是工具类的静态字段。这样页面销毁时缓存自然清理不会残留脏数据。我踩过一次比较深刻的坑之前在一个工具类里存了density的静态字段某个下载模块初始化时写入了值后来应用切换到多窗口模式density 变化了下载进度条的文案位置全部偏移。后来把所有静态可写字段全部移除问题才彻底解决。另外还有一个容易被忽略的点scaledDensity在系统字体缩放变化时也会变化所以在代码里做字体相关换算时sp2px的输入应该是 “sp 设计值”而不是“已经被某种默认 density 换算过一次的 px 值”。很多文字大小错乱的 bug 都是在这里发生的。封装工具类的过程本质上就是把“设备屏幕信息”这个系统级概念收敛成项目里一套稳定、可复用、可测试的 API。我最终保留的这套接口不算多但覆盖了日常开发里 90% 以上的屏幕尺寸相关需求。如果你项目里也有类似的散装代码建议直接拿这份思路去整理一版改完会发现很多隐藏的适配问题都浮出水面了。
返回列表