Appearance
逻辑单位、资源倍率与图片解码:dp / dpi / scale 到内存
回到总览:屏幕适配:从清晰显示到动态窗口可用相关模块:02-断点响应式布局(显示多大 / 怎么布局)· 03-字体 / 安全区 / 折叠屏 / 验证矩阵(系统边界)·
11-image-loading(怎么加载)· 07/08 Bitmap 内存(内存公式 / 降采样)
一句话定义
1. 逻辑密度锚点(dp / pt)
dp 是 Android 的逻辑密度锚点,表示在 160 DPI(也就是每英寸有 160 个像素点的屏幕)上 1 个 dp 的大小就等于 1 个物理像素(px)的大小。在高倍率比如 480 DPI 上面则 1 dp = 480/160=3 个物理像素(px)的大小。
Flutter 逻辑像素、iOS pt 与 dp 同构(px = 逻辑单位 × 倍率),Flutter 乘 devicePixelRatio、iOS 乘 @2x/@3x scale;
- Android:160 DPI 为基准桶(1 dp = 1 px),1 英寸为 25.4 mm。
- iOS:初代 iPhone 163 PPI 为基准(1 pt = 1 px)。
- 高 DPI 动态对冲:以 320 DPI 屏幕为例:
(注:厂商贴靠标准桶或虚报 DPI 时,物理尺寸会有微弱偏差。)
2. 原生 Android 位图桶与缩放机制 (inTargetDensity / inDensity)
Android 会在 native 解码阶段,强制根据设备密度与资源目录密度的比值(
- 解码与内存:放哪个桶直接决定
inDensity,系统根据inTargetDensity / inDensity决定解码尺寸与内存占用。假设使用色彩格式 ARGB_8888,则: - 资源配置建议:图标类只推荐针对主流/最高密度(如 xxhdpi / xxxhdpi)产出一份高精度切图,省略低密度桶;背景、Banner 等大图建议放入
drawable-nodpi,使用inSampleSize手动采样裁剪。 - 错位风险:
- 小图错放大桶:假设本来是 mdpi 的图(100*100px),但是放错到了 xxhdpi 中。
- xxhdpi 的大屏幕手机上:图片就在xxhdpi中,
图片加载的宽高不变,内存不变,但是原本 100dp 对应的是 (480/160)*100px=300px 的尺寸,而原本的图只有 100*100px,所以控件拉伸图片的话会变糊,不拉伸的话不能覆盖控件大小。 - mdpi 手机上:系统计算图片内存是
图片宽高为原先的 1/3,约为 33*33px,占用内存是原先的 1/9 大小,控件的大小是100dp 对应的是 (160/160)*100px=100px,所以控件拉伸图片的话会变糊,不拉伸的话不能覆盖控件大小。
- xxhdpi 的大屏幕手机上:图片就在xxhdpi中,
- 大图错放小桶,遇大屏:内存直接暴涨
倍,因为本来就是大图,内存占用更大,这是触发 OOM 崩溃的最经典场景。 - 向下、向上兼容展示(大图缩小,目录正确) :将大图正确放在 xxhdpi 桶
- 在 mdpi 设备上运行。后果:系统解码时主动缩小(下采样),内存平方级减小(如变成原来的
)。“矩阵缩放(Matrix Scaling / Density Scaling)”,极度安全,但图片视觉上可能偏小; - 在 xxxhdpi 上面展示,则。放大倍率:
倍,内存增大为原来的 倍。但画面不会发虚,因为原始像素足够高,放大了只是“变大”。
- 在 mdpi 设备上运行。后果:系统解码时主动缩小(下采样),内存平方级减小(如变成原来的
- 小图错放大桶:假设本来是 mdpi 的图(100*100px),但是放错到了 xxhdpi 中。
“只在 xxhdpi 放一套图” 已经成为绝大多数中大型 App(除极端苛刻追求完美体验的项目外)的标准操作。
- 向上到 xxxhdpi,内存涨不到 1 倍,画质肉眼无损;
- 向下到 hdpi 或以下,内存大幅缩减,安全可靠;
- 包体积(APK size)得到了完美的控制。
3. iOS 与 Flutter 资源加载与内存开销对照
- 内存占用本质:iOS 与 Flutter 加载 Asset 时的内存占用只与图片文件自身的原始物理像素有关,系统不会在解码时自动根据设备屏幕缩减内存。二者都是有类似2.0x/、3.0x/的目录放置图片,没有的时候在相邻的目录查找。
- 跨设备与错位表现:
Flutter:
- 低屏用高图:布局正常,但内存严重浪费,可能存在微弱锯齿。
- 高屏用低图:自动等比例拉伸,布局绝对安全,但画面发虚模糊。
- flutter 一般放 3x 目录或者根目录(等价),然后在使用的时候设置cacheWidth/cacheHeight(单位像素,需要逻辑像素*devicePixelRatio得到像素),这样可以 Engine 之后解码对应像素的 Bitmap 放到内存,减少内存的使用。
iOS:
- 低屏用高图:布局按 Point 限制渲染(GPU 硬件缩放),但内存依然按原图全额吃满。
- 放错倍率桶(致命) :如把 3x 像素图当作 2x 使用,系统计算 Point 错误,会直接撑破 UI 布局。
- iOS 一般提供
@2x,@3x资源,Apple store 在用户下载的时候自动分发对应的资源包。
其实不管是 Android、Flutter 还是 iOS, 图标/小元素都应该尽量使用 svg 替代位图;本地必须内置的位图(如引导页、常驻插画)
阅读地图:这篇解决什么,不解决什么
| 这篇解决(01 层) | 这篇不解决(交给 02 / 03) |
|---|---|
| 逻辑单位怎么换算物理像素(dp / pt / 逻辑像素 × 倍率) | 窗口变宽后怎么换布局(单列 → 两栏 / 断点 600/840)→ 02-断点响应式布局 |
| 位图放哪、解码多大、内存怎么算(密度桶 / nodpi / 变体目录 / 降采样) | 占屏比例怎么跟设计稿对齐(375 分份 / 比例法 / flutter_screenutil)→ 02-375 设计稿缩放 |
| 矢量 vs 倍率档 vs 单源 的资源选型 | 字体放大、安全区、折叠分屏、验证矩阵 → 03 |
| 显示尺寸 vs 解码尺寸 为何独立(约束尺寸不解码降采样仍 OOM) | 网络图加载链路细节 → 11-image-loading |
心智模型:先把「单位 + 资源 + 内存」这层地基做对,再叠 02 的布局断点,最后用 03 兜系统边界。只做好 01 仍可能出现「图清晰了但平板像放大版手机」——那是 02 的问题,不是桶放错了。
图片资源先分两类,放置位置完全不同
- ① UI 小图标 / 控件图(小尺寸、高频复用、要永远清晰):矢量优先(VectorDrawable /
flutter_svg/ PDF);位图则放高密度桶(如xxhdpi单桶)——"只缩不放",永远清晰且不膨胀包体。 - ② 背景 / 运营大图(大尺寸、一次性、要控内存):Android
drawable-nodpi单源 +inSampleSize降采样;iOS Single Scale +scaleAspectFill;Flutter 单一资源 +cacheWidth降采样。 - 口诀:小图标靠倍率档,大图靠单源 + 运行时自适应(机制详解见下文第二层 §2;内存公式见 07/08)。
代码索引
| 主题 | Lab 说明 | 源码 |
|---|---|---|
| Android 图片加载约束驱动采样 | Lab: Android 图片加载(Landscapist · Glide / Coil + 原生解码) | ImageLoadingDemoActivity.kt |
| Flutter 图片缓存与降采样 | Lab: Flutter 图片加载(CachedNetworkImage) | cached_network_image_demo_page.dart |
| Flutter asset 变体缺失实验 | Lab: Flutter asset 变体(1x 图在 3x 屏的尺寸与清晰度) | asset_variant_demo_page.dart |
为什么需要
- 为什么同一张 480px 的图放
mdpi,在 1080p 手机会占 9 倍内存还模糊?- 一句话答:1080p 手机 ≈ xxhdpi(3x),解码尺寸 = 480×(480/160) = 1440px,内存按面积放大 3² = 9 倍;而像素是离散点,放大只能靠插值“编”出不存在的信息,所以模糊(详见下文第二层 §2)。
- 为什么 UI 图标推荐矢量、背景大图推荐
nodpi?- 一句话答:矢量存“路径公式”,任意缩放都重算、永不失真;背景大图按显示尺寸降采样解码,
nodpi不参与 density 缩放,避免内存被平方放大(详见第二层 §4)。
- 一句话答:矢量存“路径公式”,任意缩放都重算、永不失真;背景大图按显示尺寸降采样解码,
- 为什么文字大小不能跟图片策略混为一谈?
- 一句话答:图片看的是像素密度和解码尺寸,文字看的是可读性和用户字体设置——布局用
dp,文字用sp,不要让图片资源策略误伤字体适配(详见第二层 §6)。
- 一句话答:图片看的是像素密度和解码尺寸,文字看的是可读性和用户字体设置——布局用
第一层:逻辑单位、倍率与换算链
本节回答:1 个逻辑单位(dp / 逻辑像素 / pt)在屏幕上对应多少物理像素?倍率(Android HDPI、iOS / Flutter 的 2×/3×)是干什么的? 布局占屏比例、375 比例法见 02-断点响应式布局。
单位词汇表(px / dpi / dp / sp / 逻辑宽)
讲适配前先把“单位”这件事一次性摆清楚。它们是同一套词汇表的不同层级,别混用:
| 单位 | 是什么 | 谁定义 | 用途 / 铁律 |
|---|---|---|---|
| px | 物理像素,屏幕最小发光点 | 屏幕硬件 | 渲染最小单位,离散、随设备变 |
| dpi / ppi | 每英寸点数(dots per inch) | 屏幕物理属性(代码改不了) | px 与真实世界的“汇率” |
| dp(Android)/ pt(iOS)/ 逻辑像素(Flutter) | 密度无关像素 = 逻辑密度锚点(抽象英寸,非绝对物理承诺) | 系统 | 布局尺寸 / 间距 |
sp(Android)/ TextUnit.Sp(Compose)/ sp(Flutter Text) | 缩放无关像素 = dp × fontScale | 系统 | 文字字号,跟随系统字体 |
| 密度桶 | mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi = 160/240/320/480/640 | Android 资源系统 | 图片按桶放置 |
| 逻辑宽 | 屏宽(dp) = 屏宽(px) ÷ (dpi/160) | 运行时算 | 真正驱动适配的变量,不是设备型号 |
补充:底层 API 核心参数与链条换算
| 参数 | 归属体系 | 对应 Android API / 字段 | 主要用途 |
|---|---|---|---|
dp | 布局层(逻辑密度锚点) | TypedValue.applyDimension() 按 DisplayMetrics.density 换算;XML 里直接写 48dp | 布局尺寸 / 间距 / 图标大小,跨密度视觉接近 |
densityDpi | 屏幕物理属性(DisplayMetrics) | DisplayMetrics.densityDpi,经 WindowManager / Resources 读取 | 屏幕真实密度,两条换算链的枢纽 |
density | 派生值 = densityDpi / 160 | DisplayMetrics.density(getResources().getDisplayMetrics().density) | 布局 px 换算乘数:px = dp × density |
inDensity | 资源解码层(BitmapFactory.Options) | BitmapFactory.Options.inDensity | 图片源文件所在密度桶的 dpi(如 xxhdpi = 480) |
inTargetDensity | 资源解码层(BitmapFactory.Options) | BitmapFactory.Options.inTargetDensity,解码时默认 = densityDpi | 目标设备 dpi,与 inDensity 共同决定解码缩放倍率 |
两条链如何被 densityDpi 统一衔接:
- 布局计算链(左) :
density = densityDpi / 160→ 把布局里写的dp乘上density得到上屏像素px = dp × density。这条链解决「控件画多大」。 - 位图解码链(右) :
inTargetDensity = densityDpi→ 与图片源桶标记inDensity相除得到解码缩放倍率Bitmap Scale = inTargetDensity / inDensity,再作用到原图像素。这条链解决「位图解码多大、占多少内存」(对应第二层 §2 的解码尺寸 = 原图 × 设备dpi/桶dpi)。
图下备注:两条链都从同一个
densityDpi(屏幕硬件)出发——布局链density = densityDpi/160再px = dp×density;解码链inTargetDensity = densityDpi再Scale = inTargetDensity/inDensity。density与inTargetDensity本质都是把densityDpi接到不同用途上:前者给布局,后者给位图解码。
铁律三条,关键时刻能救你:
- 布局 / 间距 / 图标尺寸用
dp;文字用sp。二者语义不同,别把文字也按图片思路一并缩放(见第二层 §6)。 dp / pt / 逻辑像素都是“绕开 dpi、锚定逻辑密度”的虚拟单位——这就是为什么同一套 dp 布局在 320dpi 和 480dpi 上视觉尺寸通常接近(个别虚报 dpi 机型除外)。- 适配看“当前窗口宽(逻辑宽)”,不看机型:同样 1080px 宽,密度 2.0(≈420dpi)逻辑宽 540dp、密度 2.625 逻辑宽 411dp,绝不能假设“1080p ≈ 360dp”。逻辑宽必须运行时读。
这套词汇表是后面 02(断点看窗口宽)、03(字体用 sp 跟随系统)的地基。记不住细节时,先回到“px 是硬件、dpi 是屏幕属性、dp 是逻辑密度锚点、sp 是跟随用户的字”这一句。
dp 定义、换算链与三端对照
dp 先被定义为与密度无关的抽象长度锚点(1 dp ≡ 1/160 英寸),再按设备 dpi 换算成像素——不是由像素反算出来的:
text
px = dp × (设备dpi / 160) // 倍率 s = 设备dpi/160
dp = px / (设备dpi / 160)
屏宽(dp) = 屏宽(px) × 160 / dpi- 为什么选 160? 早期 mdpi = 160dpi,该密度下 1px = 1dp,形成跨密度一致的「抽象英寸」。
- DPI 是什么? 屏幕物理属性(代码改不了);厂商按面板上报,系统再映射到密度桶。
三个平台都采用「逻辑单位 = 密度锚点」的同一思想:先把单位定义成一个与密度无关的抽象长度(单位是英寸;pt 源于初代 iPhone 163ppi),再乘上设备的倍率换算成物理像素:
| 平台 | 逻辑单位 | 换算公式 | 倍率来源 | 1 单位 ≈ 物理长度 |
|---|---|---|---|---|
| Android | dp | px = dp × (设备dpi / 160) | 设备dpi / 160(连续,可如 2.625) | 严格 1/160″ ≈ 0.1588mm |
| Flutter | 逻辑像素 | px = 逻辑像素 × devicePixelRatio | 宿主 dpr(Android 上 = density,iOS 上 = scale) | 跟随宿主:Android≈dp、iOS≈pt |
| iOS | pt | px = pt × scale | @1x/@2x/@3x 三档离散(非连续 dpi 桶) | ≈ 1/163″ ≈ 0.1558mm |
反算实例(100 个逻辑单位) :
- Android:
100dp@320dpi →100 × (320/160) = 200px;@420dpi →262.5px;@480dpi →300px - Flutter:
100逻辑像素 @devicePixelRatio 3.0→100 × 3.0 = 300px(在 Android 宿主上dpr通常等于density;在 iOS 宿主上等于屏幕scale) - iOS:
100pt@@3x→100 × 3 = 300px(UIImage/ Auto Layout 按离散 scale 1:1 匹配切图,不按 Android 式 dpi 桶二次缩放)
各端原理要点(算 px 时别混用) :
- Android
dp:系统用Resources.getDisplayMetrics().density(= dpi/160)把布局 dp 换成 px;位图另走密度桶解码(第二层)。 - Flutter 逻辑像素:
MediaQuery.devicePixelRatioOf(context)读宿主倍率;没有自己的英寸定义,在 Android 上 1 逻辑像素 ≈ 1dp,在 iOS 上 ≈ 1pt;资源用1.0x/2.0x/3.0x变体目录,解码行为与 Android 桶不同(见第二层 §5.1)。 - iOS
pt:逻辑单位锚定约 1/163″;scale只有 1 / 2 / 3 三档,UIScreen.main.scale或 trait 决定当前屏;须按@2x/@3x出切图,不做inDensity/inTargetDensity式二次解码。
⚠️ 近似相等,不是精确相等:
1dp严格 = 1/160″(0.1588mm);1pt严格 ≈ 1/163″(0.1558mm)——两者差约 2%;Flutter 逻辑像素「跟随宿主」,在 Android 上≈dp、在 iOS 上≈pt。所以「三端逻辑单位 ≈ 0.15~0.16mm」只是直觉尺子,精确换算永远走上表公式。⚠️ 上面算的是软件理想锚点;真机因厂商把 DPI 贴靠标准桶,实测物理长度会有偏差(如 403ppi 屏上报 480dpi,
160dp实测 ≈1.19″ 而非 1″)——逻辑单位保证像素级清晰与变体对齐,真机只在偏差范围内「视觉一致」。
机型与 100dp 按钮(下文只以此为例,不再重复公式):
| 机型 | 物理屏宽(px) | 系统 dpi | s=dpi/160 | 逻辑宽(dp) | 同一 100dp 渲染 px |
|---|---|---|---|---|---|
| 大屏(如 Pixel 系) | 1080 | 420 | 2.625 | 1080/2.625 ≈ 411dp | 262.5px |
| 小屏 | 720 | 320 | 2.0 | 720/2.0 = 360dp | 200px |
在理想锚点下,两机 100dp 按钮拿尺子量都 ≈ 0.625″(1.59cm) ——dp 归一化了跨 dpi 的视觉尺度;但因逻辑宽 411dp vs 360dp,占屏比例 24.3% vs 27.8%——dp 不归一化占屏比例(布局层问题,见下节与 02)。
图下备注:
1 dp ≡ 1/160 英寸是定义,DPI 是 px 与英寸的「汇率」。上图两台机上同一100dp像素数不同、物理宽接近——密度归一了,占屏比例没有。
2. 适配倍率的意义:HDPI / 2× / 3× 是干什么的
倍率 = 逻辑单位 → 物理像素的汇率(即 px = 逻辑单位 × 倍率 里的乘数)。它不是「放大开关」,而是由屏幕密度决定的客观数字,承担两重作用:
- 布局层——让 UI 在不同密度屏上视觉尺寸接近一致:同一逻辑宽度的按钮,2x 屏用更多 px、3x 屏用更多 px 绘制,在理想锚点下视觉尺度接近(见上节
100dp例)。 - 资源层——决定位图切图档位:位图是离散点阵,要 1:1 清晰必须「像素够」。48dp 图标 @2x 屏需要
96px资源、@3x 屏需要144px——这正是 iOS 须按@2x/@3x出切图、Flutter2.0x/3.0x变体目录存在的意义。
三端倍率的落地形式:
| 平台 | 倍率怎么表达 | 含义 | 一个例子 |
|---|---|---|---|
| Android | 密度桶 mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi = 1x/1.5x/2x/3x/4x | 桶是资源分档锚点;实际缩放仍按 设备dpi ÷ 桶dpi 解码(见第二层 §2) | HDPI = 240dpi = 1.5x(1dp = 1.5px,720p 中端机) |
| iOS | @1x/@2x/@3x | 离散三档,UIImage(named:) 精确 1:1 匹配 | @3x = 1pt = 3px(Super Retina) |
| Flutter | 变体目录 1.0x/2.0x/3.0x | 目录名即 scale,运行时按 devicePixelRatio 匹配(见第二层 §5.1) | 3.0x/ 目录 = 每个逻辑像素配 3 个物理像素的资源 |
为什么需要倍率(一句话) :没有倍率,UI 无法换算成像素上屏;只有倍率、没有「资源档位」,位图在高分屏会被强行放大——发糊。所以适配 = 布局靠倍率归一视觉尺寸,资源靠倍率决定切图档位,两者配合才完整。
图下备注:三端「倍率」工程落地不同(Android
dp+ 资源限定符、ComposeLocalDensity、Flutter 变体目录),但「逻辑单位 × 倍率 = 物理像素」同构;大屏断点见 02。
换算原理:dp 为什么等于 1/160 英寸
dp 不是“算出来的单位”,而是先被定义为一个物理长度锚点,再反推像素:
text
1 dp ≡ 1/160 英寸 // 定义:与设备密度无关的抽象长度锚点
1 英寸 = 2.54 cm // 固定真实长度Android 把 160dpi(mdpi)作为基准密度:在 160dpi 的屏上,1 个物理像素正好 = 1dp = 1/160 英寸。于是任意设备上:
text
px = dp × (设备dpi / 160) // (设备dpi/160) = 该设备“每 dp 用多少像素”
dp = px / (设备dpi / 160)- 为什么选 160 而不是别的? 历史原因:早期基准屏 mdpi = 160dpi,于是“160dpi 下 1px”被定为 1dp,使
dp成为跨密度一致的“抽象英寸”。 - DPI 是什么?
dpi = dots per inch,即这块物理屏幕“每英寸塞多少个点”,是屏幕的物理属性,不是你代码里能改的。设备商按面板规格上报,系统再决定走哪个密度桶。
对应关系:dp、英寸、像素、手机屏宽
一条换算链贯穿它们:
text
屏宽(物理英寸) = 屏宽(px) ÷ dpi
屏宽(dp) = 屏宽(物理英寸) × 160
⇒ 屏宽(dp) = 屏宽(px) × 160 / dpi例(常见机型实测量级):
| 机型 | 物理屏宽(px) | 系统 dpi | 缩放系数 s=dpi/160 | 逻辑宽(dp) | 物理宽(英寸) |
|---|---|---|---|---|---|
| 大屏(如 Pixel 系) | 1080 | 420 | 2.625 | 1080/2.625 ≈ 411dp | 411/160 ≈ 2.57" |
| 小屏 | 720 | 320 | 2.0 | 720/2.0 = 360dp | 360/160 = 2.25" |
关键结论:
1 dp在理想锚点下 =1/160 英寸=0.159 cm,与哪台手机无关。DPI 只是 px 与英寸之间的“汇率”,dp绕开它,直接锚定抽象长度。- 同一
100dp按钮:在大屏画成100×2.625=262.5px,在小屏画成100×2.0=200px;像素数不同,在理想锚点下物理尺寸相同,拿尺子量约都是100/160=0.625 英寸 ≈ 1.59cm。 - ⚠️ 唯一会打破“完全一致”的例外:厂商把 DPI 贴靠到标准桶(把密度 override 成整数桶)。真机物理尺寸
。例:某旗舰屏硬件真实 403ppi、厂商上报 480dpi(3x)—— 160dp被换算为160 × 3.0 = 480px,硬件按 403ppi 渲染,实测480 ÷ 403 ≈ 1.19″ ≈ 3.02cm(偏离理想 1″ 约 19%);又如物理 420dpi 的屏系统按 480 算,100dp实际画 300px,300/420″ ≠ 0.625。绝大多数机子凑得很近,但别当成绝对物理定律——逻辑单位保证像素级清晰与变体对齐,真机物理尺寸只在偏差范围内“视觉一致”。
图下备注:上图把换算链和“两台手机”对照画在一起。
1 dp ≡ 1/160 英寸是定义,DPI 只是 px 与英寸的“汇率”。手机 A(1080px@420dpi,逻辑宽 411dp)与手机 B(720px@320dpi,逻辑宽 360dp)上,同一个100dp按钮渲染出的像素数不同(262.5px vs 200px),但拿尺子量的物理宽都 ≈ 0.625″(1.59cm)。这正说明 dp 归一化了“密度”,但没归一化“占屏比例”——见下一节。
核心特性:dp 归一化密度,但不归一化占屏比例
DP 的核心特性(密度归一) :在正常(未虚报)设备上,100dp 渲染出的物理宽度在不同密度屏上趋于一致(≈1.59cm)。这保证了“不同密度屏显示一致”——同一套 dp 布局,在 320dpi 和 480dpi 上看起来一样大。
但 DP 不归一化占屏比例:两台手机逻辑宽不同(360dp vs 411dp),100dp 占屏比例就不同(27.8% vs 24.3%)——大屏上显得“小、空”,小屏上显得“大、挤”。这是布局层问题,不是资源桶问题。
占屏比例 / 设计稿对齐(375 分份、比例法、flutter_screenutil、为何不能平推平板)属于 02-断点响应式布局——含 实际值 = 设计值 × (设备逻辑宽 / 375)、clamp(1.0, 1.2) 与断点换布局。收口:01 的 dp = 密度锚点 + 资源倍率;02 的 375 / 断点 = 占屏比例与结构。
第二层:资源放置、密度桶与解码缩放
本节把「放哪、解码多大」讲透:密度桶公式、两类图策略、三端对照与 Flutter 变体匹配。
1. 密度桶:资源分档与 dpi 对照
承接第一层 px = dp × (设备dpi/160);图片资源按桶分档,桶 dpi 参与解码缩放(见 §2):
| 桶 | dpi | 倍数 | 典型设备 |
|---|---|---|---|
| mdpi | 160 | 1x | 早期低端机 |
| hdpi | 240 | 1.5x | 720p 中端机 |
| xhdpi | 320 | 2x | 720p~1080p |
| xxhdpi | 480 | 3x | 1080p 主流机(逻辑宽 ≈ 360dp) |
| xxxhdpi | 640 | 4x | 2K / 1440p+ |
例:1080p 屏 density=3,1dp = 3px,逻辑宽 = 1080/3 = 360dp。
2. 密度桶与解码缩放(为什么会“放大”)
资源系统把桶的 dpi 设为 Options.inDensity、设备 dpi 设为 inTargetDensity,BitmapFactory 解码时按 inTargetDensity / inDensity 缩放输出——目的是让图片在不同密度设备上显示成相同的逻辑尺寸(dp) 。结果:
text
解码尺寸 = 原图 × (设备dpi / 桶dpi)
内存倍率 = (设备dpi / 桶dpi)² // 宽高各放大一次⚠️ 防误读:内存是升是降,只由解码倍率
r = 设备dpi ÷ 桶dpi决定,跟图本身“大还是小”无关。r > 1(桶密度低于设备)→ 放大,内存按面积暴涨且糊;r < 1(桶密度高于设备)→ 缩小,内存下降且清晰。所以「大图放低桶」在旗舰机上是暴涨不是下降(4K 放 mdpi 桶在 4x 屏 → 解码 15360px、内存 ×16,直接 OOM);「小图放高密度设备」也只有放低桶时才暴增(放 xxhdpi 桶在 4x 屏仅 ×1.78)。
3. 桶放反的影响(同一张 480×480 原图)
| 放哪 | 1080p 屏(3x) | 2K 屏(4x) | 结论 |
|---|---|---|---|
| mdpi(1x)低桶 | 解码 1440px,内存 ×9,放大→糊 | 解码 1920px,内存 ×16,更糊 | 最差 |
| xxhdpi(3x) | 解码 480px,精确命中 | 解码 640px,内存 ×1.78,轻微 | UI 位图推荐 |
| xxxhdpi(4x)高桶 | 解码 360px,缩小→清晰 | 解码 480px,精确命中 | 2K 屏更优 |
| nodpi(不缩放) | 解码 480px(原像素) | 解码 480px(原像素) | 照片 / 背景 + 降采样 |
图下备注:mdpi 低桶在 3x/4x 屏被放大 → 内存按
(设备dpi/桶dpi)²涨且模糊;xxhdpi 是 1080p 主流屏的平衡点;nodpi 恒为原像素,适合照片/背景。
4. 两类图的放置策略
UI 小图标(返回 / 删除 / 收藏) :
- 首选 VectorDrawable(XML) ——任意密度不失真、体积最小;
- 位图则放 xxhdpi(3x)单桶:3x 设备精确命中,1x/2x 设备向下缩小(清晰)。
背景 / 照片大图:放
drawable-nodpi一张(不参与 density 缩放,解码尺寸 = 原图),加载时用inSampleSize降采样到屏幕尺寸;不要每桶各放一份(APK 体积成倍翻)。iOS 对照:小图标按
@2x/@3x出两套精确切图(离散 scale 1:1 匹配);大背景 / 运营图在 Asset Catalog 设 Single Scale(单源) 放一张原始高清图,代码contentMode = .scaleAspectFill等比铺满——同一句口诀:小图标靠倍率档,大图靠单源 + 运行时自适应(详见11/05 iOS 图片加载)。mipmap vs drawable:
mipmap-{桶}与drawable-{桶}是同一套密度桶、同一套解码公式(解码尺寸 = 原图 × 设备dpi/桶dpi,见 §2),区别只在谁放——会被系统 / Launcher 直接取用的图标(启动图标、系统级图标)放mipmap-{桶},普通业务图片放drawable-{桶}。原因:桌面 Launcher 按设备密度取图标,部分 OEM 启动器还会**“取高一档再缩小”**以获得更锐利的显示(如 xxhdpi 桌面上的 48dp 图标,去取 xxxhdpi 的 192px 缩到 144px 渲染),放 mipmap 时这类系统取用最稳。启动图标标准设计尺寸 48dp,各桶像素 =48 × 倍率:桶 倍率 48dp 图标像素 mdpi 1x 48px hdpi 1.5x 72px xhdpi 2x 96px xxhdpi 3x 144px xxxhdpi 4x 192px Android 8.0+ 自适应图标在此基础上换成「108dp 画布 + 前景 66dp 安全区 + 设备蒙版」的合成方式(见下一条)。
自适应图标(AdaptiveIconDrawable,Android 8.0+) :启动图标不再是单张方形位图,而是「前景层(主体图形,放在 108dp 画布中心的 66~72dp 安全区内)+ 背景层(纯色/图案)+ 设备蒙版(厂商决定形状:圆 / 圆角方形 / 泪滴)」三层合成——保证任何桌面形状下主体都不被裁切。放
mipmap-anydpi-v26/ic_launcher.xml,低版本仍用各密度 PNG。9-patch(
.9.png) :可拉伸位图,四周 1px 黑边定义「可拉伸区」与「内容区」——按钮背景 / 聊天气泡 / 卡片圆角这类“整体拉伸会变形”的图,用 9-patch 只拉伸中间区域,四角与圆角保持不变;与 VectorDrawable 互补(矢量能表达的形状用矢量,照片 / 复杂纹理用 9-patch)。 一般也要参考其他位图放到不同的 drawable 中。
图下备注:小图标首选矢量(任意密度不失真);位图放 xxhdpi 单桶。背景大图放 nodpi 一张 + 降采样。Flutter 按 devicePixelRatio 找变体、缺失不缩放。
5. Android / iOS / Flutter 对照
| 端 | 机制 | 关键差异 |
|---|---|---|
| Android | 连续密度 (任意 dpi) + inDensity/inTargetDensity 解码缩放 | 系统在解压成内存位图时二次重采样,低桶放高屏导致内存 |
| iOS | 离散密度 (固定 scale) + @1x/@2x/@3x 精确匹配;大图 Single Scale | 须按 @2x/@3x 出切图;1:1 物理像素点阵渲染,不做多余重采样,极清晰;官方无磁盘缓存(详见 11/05) |
| Flutter | devicePixelRatio 按 AssetImage._chooseAsset 匹配 1.0x/2.0x/3.0x 变体 | 不会按 Android density 机制扩大解码位图:无 scale ≥ dpr 变体时退回最高变体原像素解码;若只提供 1.0x 图,高分屏上按固有逻辑尺寸渲染(尺寸不变、不缩小),绘制时仍可能拉伸低清源图发糊;强制拉伸到更大逻辑尺寸则严重马赛克/模糊(详见 11/README) |
5.1 Flutter AssetImage 变体匹配与尺寸渲染计算流程
在 Flutter 工程中,AssetImage 加载打包在 asset bundle 中的图片时,变体选择与固有尺寸计算由 AssetImage._chooseAsset 源码逻辑严格控制。
第一步:变体选择规则
- 运行时获取宿主设备的物理像素比
devicePixelRatio(例如 dpr = 3.0)。 - 在
pubspec.yaml声明的变体目录列表(如assets/icon.png、assets/2.0x/icon.png、assets/3.0x/icon.png)中,筛选出 第一个满足 scale >= dpr 的变体(如命中3.0x/目录,scale = 3.0)。 - 缺失回退机制:若没有找到任何 scale >= dpr 的变体,则直接退回选择已声明的最高 scale 变体(若只在根目录放了一张图,Flutter 读取该图,默认 key.scale = 1.0)。
第二步:固有逻辑尺寸计算公式 (Intrinsic Logical Size)
当代码中未显式设置 Image Widget 的 width/height 时,Flutter 根据原图物理像素与目录标记的 scale 计算布局中占用的逻辑空间:
显示逻辑宽度 = 源图原始物理像素 / 目录标记的 scale第三步:物理点阵与画质表现(以 48px 原图在 3.0x 屏渲染为例)
场景 A:仅配置根目录
1.0x图(key.scale = 1.0,最常见误解)- 逻辑框:48 / 1.0 = 48 逻辑像素;屏幕需 48 × 3.0 = 144 物理像素。
- GPU 须将 48px 双线性插值放大 3 倍至 144px。
- 结果:显示尺寸正常(48pt),画质极度模糊发虚!
场景 B:配置
3.0x/变体目录(key.scale = 3.0,正确做法)- 原图为 144px;逻辑框:144 / 3.0 = 48 逻辑像素;屏幕需 144 物理像素。
- 原图 144px 与屏幕 144 物理点 1:1 精确点阵映射。
- 结果:显示尺寸正常(48pt),画质极度锐利清晰!
场景 C:错将 48px 小图塞入
3.0x/目录(放错目录)- 逻辑框:48 / 3.0 = 16 逻辑像素;屏幕需 48 物理像素,1:1 清晰映射。
- 结果:画质清晰,但视觉尺寸缩成 16pt(原本 48pt 的 1/3 宽高)!
| 原图物理像素 | 存放目录 | scale | 固有逻辑框 | 3x 屏需物理点 | GPU 动作 | 最终尺寸 | 清晰度 |
|---|---|---|---|---|---|---|---|
| 48 px | 根目录(1.0x) | 1.0 | 48pt | 144 px | 放大 3 倍 | 48pt(正常) | 极度模糊 |
| 144 px(标准) | 3.0x/ | 3.0 | 48pt | 144 px | 1:1 对齐 | 48pt(正常) | 极度清晰 |
| 48 px(错放) | 3.0x/ | 3.0 | 16pt | 48 px | 1:1 对齐 | 16pt(缩至 1/3) | 极度清晰 |
"缺失不缩放"的物理表现(可于 labs/flutter/.../asset_variant_demo_page.dart 实验室实测):
- 现象 A(无逻辑宽高约束) :在 3.0x 手机上仅配置
1.0x图时,Flutter 不会在解码期按 density 自动放大 3 倍。该图按固有逻辑尺寸渲染(48px ÷ 1.0 = 48dp,与 1x 设备上一样大、绝不缩小),只是 48px 源像素被 GPU 拉到 144px 物理点阵,肉眼可见发虚。 - 现象 B(指定逻辑宽高 / 布局强行铺满) :代码中指定逻辑尺寸(如
width: 100),屏幕需要 300 物理像素。Flutter 将该1.0x位图直接硬拉伸渲染,导致点阵充斥马赛克与严重锯齿。 - 优化收口:小图标需配置全套
1.0x/2.0x/3.0x变体或使用flutter_svg矢量图;背景大图不要挂多倍变体目录,声明单一资源 + 解码层cacheWidth/cacheHeight降采样(详见11/04 Flutter 图片加载)。
6. dp、sp、图片资源:别混着治
很多“屏幕适配看起来怪”的问题,其实不是同一个层次:
- 布局尺寸:看
dp - 文字大小:看
sp - 图片清晰度与内存:看密度桶 /
nodpi/ 降采样
举个最常见的错误:
text
错误思路:为了让页面“整体一致”,把按钮高度、图标、文字都按同一个缩放系数放大。
结果:图标可能变清楚了,但文字在系统字体放大下炸行,或者按钮点击区不够。所以真实页面里通常是:
- 容器间距、卡片尺寸:
dp - 文本字号:
sp - 图标资源:矢量 / 对应 density 桶
- 背景图:
nodpi + inSampleSize
对应 Lab:想看“显示尺寸影响解码尺寸”的真实行为,可直接看 Android 图片加载 lab 和 Flutter 图片加载 lab。前者能观察约束驱动采样,后者能观察
memCacheWidth降采样。
7. Compose:继承 Android 密度体系,用 Dp 类型
Compose 是 Android 的声明式 UI,没有自己的一套密度桶——它完全继承 View 时代的资源体系,只是把“布局单位”换成了 Kotlin 类型 Dp:
Dp就是 dp:Modifier.size(48.dp)里的48.dp与 View 的48dp语义完全一致,渲染时按LocalDensity.current.density(= 设备dpi / 160)换算成物理像素——同一个资源体系、同一个换算公式。- 读密度 / 精确换算:
val density = LocalDensity.current.density;需要 px 时with(LocalDensity.current) { 48.dp.toPx() }。 - 图片资源:
Image(painterResource(R.drawable.ic_close))照样走drawable-{桶}/nodpi/VectorDrawable——§4 的放置策略原样适用;painterResource对 VectorDrawable 按矢量渲染、位图按桶解码。 - 与 Flutter 的关键差异:Flutter 用自己的
1.0x/2.0x/3.0x变体目录(见 §5.1),Compose 没有变体目录这回事,直接吃 Android 的密度桶;文字用TextUnit.Sp(12.sp)跟随系统字体,最小点击区 / 语义尺寸见 03。
第三层:解码内存、无约束渲染与工程落地
本节衔接第二层解码机制与 07/08 Bitmap 内存:显示尺寸与解码尺寸是两个独立概念,但图片加载库也可能根据目标显示尺寸决定解码尺寸。
未显式指定尺寸时:组件尺寸与内存
本节补充两个容易混淆的问题:不设置图片组件宽高时,四个平台如何计算显示尺寸;为什么图片缩小显示后,解码内存可能仍然很大。下面以一张 4000×3000 的照片为例。
需要先区分:
- 源文件像素:图片文件中的像素尺寸;
- 解码像素:运行时 Bitmap 或图像对象实际拥有的像素尺寸;
- 固有尺寸:图片组件没有明确尺寸时使用的首选尺寸;
- 最终组件尺寸:经过父布局约束和组件测量后的尺寸;
- 绘制尺寸:图片在组件内部经过缩放或裁切后的尺寸。
此外,“没有给图片设置宽高”不等于“父级完全没有约束”。只有当父级在对应方向没有提供有限的最大尺寸时,组件才真正处于无界布局。
1. 组件尺寸:无约束时按固有尺寸计算
固有尺寸通常由运行时解码像素和图片的 scale 或 density 换算得到,但 Android View 的 ImageView 需要以 Drawable 的固有尺寸为准,不能简单理解为始终使用位图原始像素。
| 平台 | 无显式尺寸且父级允许时的计算方式 | 4000×3000 照片的示例结果 |
|---|---|---|
| Flutter | Image 的固有逻辑尺寸通常为:解码像素 ÷ 图片 scale。Image.file、Image.memory 和网络图片通常默认 scale = 1.0;资源图片的 scale 可能来自资源目录。 | 4000×3000 logical pixels |
| Android View | wrap_content 使用 Drawable.getIntrinsicWidth/Height()。对于 BitmapDrawable,固有尺寸可能经过资源 density 换算;放在 drawable-nodpi 且未缩放时,才接近原始 4000×3000 px。 | drawable-nodpi 下约为 4000×3000 px;密度资源可能不同 |
| Compose | Image 使用 Painter 的固有尺寸。若 Painter 的固有尺寸为运行时 4000×3000 px,设备 density 为 3,则约为 1333×1000 dp。资源加载过程可能先改变 Bitmap 的实际像素尺寸。 | 约 1333×1000 dp |
| iOS | UIImage.size 通常为:像素尺寸 ÷ UIImage.scale。4000×3000 像素、scale = 3 时约为 1333×1000 pt。 | 约 1333×1000 pt |
因此,更准确的共性描述是:
图片组件在没有明确尺寸且父级允许时,会使用图片对象或 Drawable 提供的固有尺寸。Flutter 和 iOS 通常可以理解为“像素 ÷ scale”;Compose 需要将 Painter 的像素固有尺寸转换为 dp;Android View 则应以 Drawable 的密度换算后固有尺寸为准。
如果父级提供了有限的最大宽高,组件可能被测量为更小的尺寸。如果父级处于滚动容器或其他无界布局中,组件也可能继续保持固有尺寸,从而超出屏幕或产生溢出。
组件尺寸确定后,图片如何绘制由对应的缩放策略决定:
- Flutter:
BoxFit.contain、BoxFit.cover、BoxFit.fill; - Android View:
ImageView.scaleType; - Compose:
ContentScale.Fit、ContentScale.Crop、ContentScale.FillBounds; - iOS:
contentMode = .scaleAspectFit、.scaleAspectFill、.scaleToFill。
父级约束决定组件可以占多大空间;fit、contentScale 或 contentMode 决定图片如何放入这个空间。仅设置缩放策略,不一定会改变组件本身的测量尺寸。
2. 内存:主要由解码像素决定
对于已经解码的 ARGB_8888 位图,像素内存可近似计算为:
text
内存 ≈ 解码宽度 × 解码高度 × 每像素字节数ARGB_8888 通常约为 4 B/px:
| 场景 | 解码像素 | 估算像素内存 |
|---|---|---|
| 全量解码 | 4000×3000 | 约 45.8 MiB |
显示为 400×300 dp,density 为 3,但没有降采样 | 仍为 4000×3000 | 仍约 45.8 MiB |
解码期限制为 800×600 | 800×600 | 约 1.83 MiB |
800×600 相比 4000×3000 的像素内存约减少 96%。
反直觉点是:
图片缩小显示,不代表 Bitmap 已经缩小。若图片已经以
4000×3000解码完成,之后再缩放到400×300 dp,通常不能减少这张 Bitmap 已经占用的像素内存。
但“没有给组件设置尺寸”也不必然等于“全分辨率解码”。是否全量解码取决于图片加载链路:
- Flutter:没有提供
cacheWidth/cacheHeight时,通常不会主动按显示尺寸降采样; - Android 原生:未使用
inSampleSize等参数时,通常会按完整尺寸解码; - Glide:会根据目标尺寸决定请求尺寸;如果
ImageView的尺寸无法确定或最终等于原始尺寸,可能无法有效降采样; - iOS:
UIImage(data:)或普通图片加载方式不能作为大图降采样方案,通常应使用 ImageIO 的缩略图 API; - Compose:Compose 负责布局,不会自动保证底层图片已经按目标尺寸解码,实际行为取决于 Painter 和图片加载库。
因此需要区分:
text
显示尺寸 ≠ 解码尺寸但图片加载库也可以使用显示尺寸作为输入,主动选择更小的解码尺寸:
text
显示尺寸 → 加载库计算目标尺寸 → 解码期降采样 → 更小的 Bitmap内存的主要像素成本由解码像素决定;完整的图片内存还可能包括压缩数据、缓存副本、GPU 纹理和变换临时缓冲区。
图下备注:以 4000×3000 照片为例。没有显式尺寸时,组件可能使用图片的固有尺寸;如果父级给出有限约束,组件可以被测量为更小尺寸。内存主要取决于实际解码像素:全量解码约 45.8 MiB,解码期降采样到 800×600 约 1.83 MiB,像素内存约减少 96%。
3. 工程结论:尺寸靠约束,内存靠降采样
- 尺寸控制:给组件设置明确的尺寸或约束,再配合缩放策略——
SizedBox/ConstrainedBox+BoxFit(Flutter)、Modifier.size/aspectRatio+ContentScale(Compose)、ImageView 布局尺寸 +scaleType(Android View)、Auto Layout 约束 +contentMode(iOS)。 - 内存控制:在解码期降采样——Flutter
cacheWidth/cacheHeight、AndroidinJustDecodeBounds+inSampleSize(Glideoverride())、iOSCGImageSourceCreateThumbnailAtIndex,让解码像素接近实际显示所需的物理像素。 - 两者独立,缺一个都是坑:只约束尺寸不解码降采样,内存仍可能很大;只降采样不约束,组件固有尺寸可能不符合布局预期。
资源问题的工程决策流程
接到一张图或一类资源需求时,按下面顺序决策(避免先写布局再回头改桶):
text
1. 分素材类型
UI 小图标 / 控件 → 矢量优先;位图走倍率档
背景 / 运营大图 → 单源 + 运行时降采样
启动图标 → mipmap + 自适应图标规则
2. 算目标物理像素
目标 px = 显示逻辑尺寸 × 设备倍率(或目标屏 dpr)
例:48dp 图标 @3x → 144px
3. 选矢量 / 倍率档 / 单源
Android:矢量 / xxhdpi 单桶 / nodpi
iOS:@2x/@3x 切图 / Single Scale
Flutter:1.0x/2.0x/3.0x 变体 / 单 asset + cacheWidth
4. 约束显示尺寸
给 Image / ImageView 宽高或 fit,避免无约束按固有尺寸撑屏
5. 解码降采样
大图:inSampleSize / cacheWidth / ImageIO 缩略图
目标:解码像素 ≈ 显示需要的物理像素(可略大留锐化空间)
6. 验证
读解码宽高(inJustDecodeBounds / getByteCount)
对照内存公式;高分屏真机看清晰度
Lab:Android 图片加载 / Flutter asset 变体 / CachedNetworkImageAndroid / Flutter / Web / Backend 对照
| 维度 | Android | Flutter | Web |
|---|---|---|---|
| 逻辑单位 | dp(160dpi 基准,逻辑密度锚点) | 逻辑像素(≈dp,devicePixelRatio 换算) | CSS px + DPR |
| 资源密度 | drawable-{桶} / mipmap- | asset 变体目录 1.0x/2.0x/3.0x | srcset / media queries |
| 缺失行为 | 按 density 缩放(可能放大) | 解码不随 density 放大;绘制可能拉伸低清源 | 浏览器按 DPR 缩放 |
| 文字单位 | sp | textScaleFactor / TextScaler | rem / em |
常见场景
1. 背景大图:nodpi + 降采样(标准做法)
java
// drawable-nodpi 放一张高分辨率原图;加载时按屏幕尺寸降采样
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inJustDecodeBounds = true; // 只读宽高
BitmapFactory.decodeResource(res, R.drawable.bg, opts);
opts.inSampleSize = calculateInSampleSize(opts, screenW, screenH); // 见 07/08
opts.inJustDecodeBounds = false;
Bitmap bg = BitmapFactory.decodeResource(res, R.drawable.bg, opts);2. 小图标:矢量优先,位图放 xxhdpi
xml
<!-- 矢量图标:任意密度不失真 -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>3. Flutter 资源变体:为什么只放 1x 图也能跑
yaml
assets:
- assets/avatar.png
- assets/2.0x/avatar.png
- assets/3.0x/avatar.pngdart
// 读取当前设备倍率(dpr),判断资源会命中哪一档变体
final double dpr = MediaQuery.devicePixelRatioOf(context); // 如 2.625 / 3.0
// 375 设计稿缩放属于布局层,详见 02「375 设计稿缩放」
ScreenUtilInit(designSize: const Size(375, 812), builder: (context, child) => child!);
final fullWidth = 375.dp; // compact 小屏补偿,不可平推平板
final fontSize = 12.sp; // 字号(sp 语义)- sp 注意:
flutter_screenutil的.sp默认allowFontScaling: false(锁死、不跟随系统字体);要「跟随系统」需显式开启——见 03 字体 / 安全区 / 折叠屏。 - 有 2x/3x 变体:高分屏更清晰
- 没有 2x/3x 变体:也能显示,但原图会被放大绘制,可能略糊
- 大图例外:背景 / Banner 大图不要挂
2.0x/3.0x变体目录,声明为单一资源即可(与 iOS Single Scale 同理);显示尺寸自适应 + 解码期降采样(cacheWidth/ 服务端两档),而不是为它准备多倍率切图。 - 对应 Lab:想看“1x 图在 3x 屏的尺寸与清晰度、变体选择与缺失不缩放”的真实行为,见 Flutter asset 变体实验 lab。
常见误配、事故后果与排障
- 大图放低桶(如 480px 放 mdpi):3x 设备内存 ×9、显示模糊。修法:放 xxhdpi / nodpi,配合
inSampleSize降采样。 - 每个桶各放一份大图:APK 体积成倍翻。修法:大图只放 nodpi 或一个高桶。
- 把图标放 nodpi:失去 dp 物理尺寸语义——要么显示得小(像素没换算),要么 View 拉伸可能糊。修法:图标用矢量或 xxhdpi 桶。
- 背景图不降采样直接加载:整图进内存(4K 图约 31.6 MiB+)。修法:
inJustDecodeBounds+inSampleSize按屏幕尺寸解码(见 07/08)。 - 把文字也按图片思路一并缩放:系统大字体下炸行、点击区不足。修法:文字用
sp/text scaler,布局和图片资源策略分开治理。 - 布局写死
px:不同密度屏上视觉尺寸、占屏比例全乱(360dp 屏上 100px 与 411dp 屏上 100px 观感完全不同)。修法:布局 / 间距 / 图标尺寸一律用dp(Compose 用Dp、Flutter 用逻辑像素),px只留给图片解码尺寸等底层场景。
排障路径:先确认“解码出来的实际宽高”(inJustDecodeBounds / getByteCount())→ 按 解码尺寸 = 原图 × (设备dpi/桶dpi) 反推放错哪个桶 → 修正放置 + 降采样 → 再检查文字和点击区是不是另一个问题。
与相近概念对比
第一层「单位词汇表」已定义 px / dpi / dp / sp;下表侧重复习速查与资源相关概念:
| 概念 | 含义 | 关系 |
|---|---|---|
| px | 物理像素 | px = dp × density |
| dp | 密度无关像素(逻辑密度锚点) | 布局用 dp,跨密度视觉接近 |
| dpi | 每英寸像素(密度) | 决定 dp→px 换算 |
| sp | 随字体缩放(用户设置) | 文字用 sp,布局用 dp |
| nodpi | 不参与 density 缩放的资源目录 | 解码尺寸 = 原图像素 |
对应实验
| Lab | 说明 | 源码 |
|---|---|---|
| Lab: Android 图片加载(Landscapist · Glide / Coil + 原生解码) | 观察 Compose 约束变化时,引擎如何按显示尺寸降采样;反证“显示尺寸”和“解码尺寸”是联动的 | ImageLoadingDemoActivity.kt |
| Lab: Flutter 图片加载(CachedNetworkImage) | 观察 memCacheWidth 降采样与磁盘 / 内存缓存差异,理解 Flutter 的图片变体与显示清晰度 | cached_network_image_demo_page.dart |
| Lab: Flutter asset 变体(1x 图在 3x 屏的尺寸与清晰度) | 同一素材只有 1x / 带 1x/2x/3x 变体对照:自然尺寸 48dp 发虚、强制 120dp 拉糊、变体命中 1:1 清晰;滑块模拟 dpr 观察变体选择规则与「缺失不缩放」(解码像素不被 density 放大) | asset_variant_demo_page.dart |
复习检查题
1080p(3x)手机上一个 48dp 的图标,需要多少 px 的位图才“正好”?
答:48dp × 3 = 144px。提供 144px 的图放 xxhdpi 桶即可精确命中;放 mdpi 桶会被放大 3 倍(内存 ×9 且糊)。
drawable-nodpi和drawable-xxhdpi的差别是什么?答:nodpi 不参与 density 缩放,解码尺寸恒等于原图像素,适合“像素即真理”的照片/背景;xxhdpi 参与缩放,在 3x 设备精确命中、低密度设备向下缩,适合需要 dp 物理尺寸语义的 UI 图标。
Flutter 只放一张 1x 图,在高分屏(3x)上会怎样?
答:按 devicePixelRatio 找不到 2x/3x 变体时用原图、解码不随 density 放大——内存不被 density 机制撑大,但 绘制时 GPU 可能拉伸低清源图,可能略糊;想要清晰需按像素比放变体或用
flutter_svg。为什么“图标清晰了”不代表“页面适配就做好了”?
答:因为页面是否适配好还取决于文字可读性、点击区、安全区和布局结构。图片资源只解决“显示得清不清楚”和“内存炸不炸”,不解决系统栏遮挡或大字体炸行。
为什么
100dp在不同密度屏上视觉宽度趋于一致?占屏比例为何仍会因逻辑宽不同而变化?答:
1dp ≡ 1/160 英寸(理想锚点),所以100dp在理想锚点下恒约0.625 英寸 ≈ 1.59cm,与设备密度无关(仅个别虚报 DPI 的机型会轻微偏离)。但 dp 不归一化占屏比例——360dp 宽与 411dp 宽手机上同一100dp占屏比例不同。要让设计稿比例在各手机上一致,需 02 的 375 分份 / 断点换布局,而不是在 01 里只靠换桶解决。1dp / 1 逻辑像素 / 1pt 分别怎么换算成物理像素?为什么说三者“近似相等”?HDPI 与 2×/3× 是什么关系?
答:公式同构
px = 逻辑单位 × 倍率:Androiddp × (设备dpi/160)、Flutter逻辑像素 × devicePixelRatio、iOSpt × scale。近似相等是因为三者的逻辑单位都是“密度锚点”——1dp 严格 = 1/160″(0.1588mm)、1pt ≈ 1/163″(0.1558mm,差约 2%)、Flutter 跟随宿主。HDPI 是 Android 密度桶的一档(240dpi = 1.5x,1dp = 1.5px),与 iOS@2x/@3x、Flutter2.0x/3.0x变体目录是“同一件事的不同叫法”——都是倍率:既用于布局换算物理像素(100dp @2x=200px),也用于资源切图档位(48dp 图标 @2x 需 96px、@3x 需 144px)。自适应图标(AdaptiveIconDrawable)为什么能适应不同厂商的桌面形状?它和 9-patch 有什么区别?
答:自适应图标由「前景层(主体图形,放在安全区内)+ 背景层 + 设备蒙版」三层合成,蒙版形状由厂商设备决定(圆 / 圆角方形 / 泪滴),运行时系统裁剪——所以主体在任何形状下都不被裁掉。9-patch 解决的是“位图整体拉伸会变形”(用黑边定义可拉伸区 / 内容区),自适应图标解决的是“图标被不同形状蒙版裁切”——用途完全不同:前者用于按钮背景 / 气泡等可拉伸图,后者用于启动图标。
把大图显示尺寸缩小(例如
Image设width: 100),为什么内存往往不会跟着下降?答:内存由解码进 RAM 的位图像素决定,不是由屏幕上画多大决定。默认路径往往是「先按全图或桶规则全量解码 → 再 GPU 缩放到显示框」。只改
width/height或BoxFit只影响绘制阶段,若未同步inSampleSize/cacheWidth/ Glideoverride()/ ImageIO 缩略图,解码仍是 4000×3000,内存仍 ≈45.8 MiB。必须让解码像素 ≈ 显示需要的物理像素才能省内存(见第三层「无约束渲染」与 07/08)。
速记
- 换算:
px = dp × (dpi/160);1080p ≈ 360dp 逻辑宽。 - 三端同构:
px = 逻辑单位 × 倍率——Androiddp×(dpi/160)、Flutter 逻辑像素×dpr、iOSpt×@2x/@3x;1 逻辑单位 ≈ 0.15~0.16mm(dp 严格 1/160″、pt ≈ 1/163″,差约 2%)。 - 倍率两用:布局归一视觉尺寸(100dp @2x=200px、@3x=300px)、资源定切图档位(48dp 图标 @2x=96px、@3x=144px);HDPI=240dpi=1.5x 是 Android 桶体系一档;iOS 须
@2x/@3x。 - 放图:图标 → 矢量 / xxhdpi 单桶;背景大图 → nodpi + 降采样。
- 低桶是坑:内存按
(设备dpi/桶dpi)²放大,且模糊。 - Flutter 单图:解码不随 density 放大,绘制可能拉伸低清源发糊;iOS 用 @2x/@3x 精确匹配。
- 显示 ≠ 解码:缩显示框不解码降采样,内存照炸。
- 资源适配 ≠ 页面适配:图片、文字、点击区、安全区要分层处理;占屏比例见 02。