Skip to content

逻辑单位、资源倍率与图片解码: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。1 dp=25.4 mm160≈0.1588 mm
  • iOS:初代 iPhone 163 PPI 为基准(1 pt = 1 px)。1 pt=25.4 mm163≈0.1558 mm
  • 高 DPI 动态对冲:以 320 DPI 屏幕为例:控件宽度 1dp=2 px=2×(1 英寸320)=1 英寸160≈0.1588 mm(注:厂商贴靠标准桶或虚报 DPI 时,物理尺寸会有微弱偏差。)

2. 原生 Android 位图桶与缩放机制 (inTargetDensity / inDensity) ​

Android 会在 native 解码阶段,强制根据设备密度与资源目录密度的比值(inTargetDensityinDensity)对位图进行自动像素重采样,从而导致解码内存占用随密度比值呈平方级放大。

  • 解码与内存:放哪个桶直接决定 inDensity,系统根据 inTargetDensity / inDensity 决定解码尺寸与内存占用。假设使用色彩格式 ARGB_8888,则:最终内存占用 (Byte)=(原图宽×原图高×4)×(inTargetDensityinDensity)2
  • 资源配置建议:图标类只推荐针对主流/最高密度(如 xxhdpi / xxxhdpi)产出一份高精度切图,省略低密度桶;背景、Banner 等大图建议放入 drawable-nodpi,使用 inSampleSize 手动采样裁剪。
  • 错位风险:
    • 小图错放大桶:假设本来是 mdpi 的图(100*100px),但是放错到了 xxhdpi 中。
      • xxhdpi 的大屏幕手机上:图片就在xxhdpi中,最终内存占用 (Byte)=(100px×100px×4)×(480480)2图片加载的宽高不变,内存不变,但是原本 100dp 对应的是 (480/160)*100px=300px 的尺寸,而原本的图只有 100*100px,所以控件拉伸图片的话会变糊,不拉伸的话不能覆盖控件大小。
      • mdpi 手机上:系统计算图片内存是最终内存占用 (Byte)=(100px×100px×4)×(160480)2图片宽高为原先的 1/3,约为 33*33px,占用内存是原先的 1/9 大小,控件的大小是100dp 对应的是 (160/160)*100px=100px,所以控件拉伸图片的话会变糊,不拉伸的话不能覆盖控件大小。
    • 大图错放小桶,遇大屏:内存直接暴涨 (inTargetDensityinDensity)2 倍,因为本来就是大图,内存占用更大,这是触发 OOM 崩溃的最经典场景。
    • 向下、向上兼容展示(大图缩小,目录正确) :将大图正确放在 xxhdpi 桶
      • 在 mdpi 设备上运行。后果:系统解码时主动缩小(下采样),内存平方级减小(如变成原来的 1/9)。“矩阵缩放(Matrix Scaling / Density Scaling)”,极度安全,但图片视觉上可能偏小;
      • 在 xxxhdpi 上面展示,则。放大倍率:640÷480=43≈1.33 倍,内存增大为原来的 (43)2≈1.78 倍。但画面不会发虚,因为原始像素足够高,放大了只是“变大”。

“只在 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 替代位图;本地必须内置的位图(如引导页、常驻插画) → 单张清晰的 WebP 格式,其他的大图(Banner)等应该使用 CND+网络缓存。

阅读地图:这篇解决什么,不解决什么 ​

这篇解决(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/640Android 资源系统图片按桶放置
逻辑宽屏宽(dp) = 屏宽(px) ÷ (dpi/160)运行时算真正驱动适配的变量,不是设备型号

补充:底层 API 核心参数与链条换算 ​

参数归属体系对应 Android API / 字段主要用途
dp布局层(逻辑密度锚点)TypedValue.applyDimension() 按 DisplayMetrics.density 换算;XML 里直接写 48dp布局尺寸 / 间距 / 图标大小,跨密度视觉接近
densityDpi屏幕物理属性(DisplayMetrics)DisplayMetrics.densityDpi,经 WindowManager / Resources 读取屏幕真实密度,两条换算链的枢纽
density派生值 = densityDpi / 160DisplayMetrics.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 枢纽:布局换算链与位图解码链如何通过 densityDpi 统一衔接

图下备注:两条链都从同一个 densityDpi(屏幕硬件)出发——布局链 density = densityDpi/160 再 px = dp×density;解码链 inTargetDensity = densityDpi 再 Scale = inTargetDensity/inDensity。density 与 inTargetDensity 本质都是把 densityDpi 接到不同用途上:前者给布局,后者给位图解码。

铁律三条,关键时刻能救你:

  1. 布局 / 间距 / 图标尺寸用 dp;文字用 sp。二者语义不同,别把文字也按图片思路一并缩放(见第二层 §6)。
  2. dp / pt / 逻辑像素 都是“绕开 dpi、锚定逻辑密度”的虚拟单位——这就是为什么同一套 dp 布局在 320dpi 和 480dpi 上视觉尺寸通常接近(个别虚报 dpi 机型除外)。
  3. 适配看“当前窗口宽(逻辑宽)”,不看机型:同样 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 是什么? 屏幕物理属性(代码改不了);厂商按面板上报,系统再映射到密度桶。

三个平台都采用「逻辑单位 = 密度锚点」的同一思想:先把单位定义成一个与密度无关的抽象长度(单位是英寸;1 英寸=25.4 mm,故 1 dp=25.4÷160≈0.1588 mm,1 pt=25.4÷163≈0.1558 mm——pt 源于初代 iPhone 163ppi),再乘上设备的倍率换算成物理像素:

平台逻辑单位换算公式倍率来源1 单位 ≈ 物理长度
Androiddppx = dp × (设备dpi / 160)设备dpi / 160(连续,可如 2.625)严格 1/160″ ≈ 0.1588mm
Flutter逻辑像素px = 逻辑像素 × devicePixelRatio宿主 dpr(Android 上 = density,iOS 上 = scale)跟随宿主:Android≈dp、iOS≈pt
iOSptpx = 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)系统 dpis=dpi/160逻辑宽(dp)同一 100dp 渲染 px
大屏(如 Pixel 系)10804202.6251080/2.625 ≈ 411dp262.5px
小屏7203202.0720/2.0 = 360dp200px

在理想锚点下,两机 100dp 按钮拿尺子量都 ≈ 0.625″(1.59cm) ——dp 归一化了跨 dpi 的视觉尺度;但因逻辑宽 411dp vs 360dp,占屏比例 24.3% vs 27.8%——dp 不归一化占屏比例(布局层问题,见下节与 02)。

px / dpi / dp / 英寸 换算关系:两台手机上 100dp 按钮像素数不同但物理尺寸一致

图下备注:1 dp ≡ 1/160 英寸 是定义,DPI 是 px 与英寸的「汇率」。上图两台机上同一 100dp 像素数不同、物理宽接近——密度归一了,占屏比例没有。

2. 适配倍率的意义:HDPI / 2× / 3× 是干什么的 ​

倍率 = 逻辑单位 → 物理像素的汇率(即 px = 逻辑单位 × 倍率 里的乘数)。它不是「放大开关」,而是由屏幕密度决定的客观数字,承担两重作用:

  1. 布局层——让 UI 在不同密度屏上视觉尺寸接近一致:同一逻辑宽度的按钮,2x 屏用更多 px、3x 屏用更多 px 绘制,在理想锚点下视觉尺度接近(见上节 100dp 例)。
  2. 资源层——决定位图切图档位:位图是离散点阵,要 1:1 清晰必须「像素够」。48dp 图标 @2x 屏需要 96px 资源、@3x 屏需要 144px——这正是 iOS 须按 @2x/@3x 出切图、Flutter 2.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 View / Compose / Flutter 三端屏幕适配实现对照

图下备注:三端「倍率」工程落地不同(Android dp + 资源限定符、Compose LocalDensity、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 系)10804202.6251080/2.625 ≈ 411dp411/160 ≈ 2.57"
小屏7203202.0720/2.0 = 360dp360/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 成整数桶)。真机物理尺寸 =逻辑单位×系统上报dpi/160硬件真实ppi。例:某旗舰屏硬件真实 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。绝大多数机子凑得很近,但别当成绝对物理定律——逻辑单位保证像素级清晰与变体对齐,真机物理尺寸只在偏差范围内“视觉一致”。

px / dpi / dp / 英寸 换算关系:两台手机上 100dp 按钮像素数不同但物理尺寸一致

图下备注:上图把换算链和“两台手机”对照画在一起。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倍数典型设备
mdpi1601x早期低端机
hdpi2401.5x720p 中端机
xhdpi3202x720p~1080p
xxhdpi4803x1080p 主流机(逻辑宽 ≈ 360dp)
xxxhdpi6404x2K / 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(原像素)照片 / 背景 + 降采样

桶放反的影响:同一张 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 图标像素
    mdpi1x48px
    hdpi1.5x72px
    xhdpi2x96px
    xxhdpi3x144px
    xxxhdpi4x192px

    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 解码缩放系统在解压成内存位图时二次重采样,低桶放高屏导致内存 r2 暴涨 + 插值模糊
iOS离散密度 (固定 scale) + @1x/@2x/@3x 精确匹配;大图 Single Scale须按 @2x/@3x 出切图;1:1 物理像素点阵渲染,不做多余重采样,极清晰;官方无磁盘缓存(详见 11/05)
FlutterdevicePixelRatio 按 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 源码逻辑严格控制。

第一步:变体选择规则

  1. 运行时获取宿主设备的物理像素比 devicePixelRatio(例如 dpr = 3.0)。
  2. 在 pubspec.yaml 声明的变体目录列表(如 assets/icon.png、assets/2.0x/icon.png、assets/3.0x/icon.png)中,筛选出 第一个满足 scale >= dpr 的变体(如命中 3.0x/ 目录,scale = 3.0)。
  3. 缺失回退机制:若没有找到任何 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.048pt144 px放大 3 倍48pt(正常)极度模糊
144 px(标准)3.0x/3.048pt144 px1:1 对齐48pt(正常)极度清晰
48 px(错放)3.0x/3.016pt48 px1: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 照片的示例结果
FlutterImage 的固有逻辑尺寸通常为:解码像素 ÷ 图片 scale。Image.file、Image.memory 和网络图片通常默认 scale = 1.0;资源图片的 scale 可能来自资源目录。4000×3000 logical pixels
Android Viewwrap_content 使用 Drawable.getIntrinsicWidth/Height()。对于 BitmapDrawable,固有尺寸可能经过资源 density 换算;放在 drawable-nodpi 且未缩放时,才接近原始 4000×3000 px。drawable-nodpi 下约为 4000×3000 px;密度资源可能不同
ComposeImage 使用 Painter 的固有尺寸。若 Painter 的固有尺寸为运行时 4000×3000 px,设备 density 为 3,则约为 1333×1000 dp。资源加载过程可能先改变 Bitmap 的实际像素尺寸。约 1333×1000 dp
iOSUIImage.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×600800×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、Android inJustDecodeBounds + inSampleSize(Glide override())、iOS CGImageSourceCreateThumbnailAtIndex,让解码像素接近实际显示所需的物理像素。
  • 两者独立,缺一个都是坑:只约束尺寸不解码降采样,内存仍可能很大;只降采样不约束,组件固有尺寸可能不符合布局预期。

资源问题的工程决策流程 ​

接到一张图或一类资源需求时,按下面顺序决策(避免先写布局再回头改桶):

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 变体 / CachedNetworkImage

Android / Flutter / Web / Backend 对照 ​

维度AndroidFlutterWeb
逻辑单位dp(160dpi 基准,逻辑密度锚点)逻辑像素(≈dp,devicePixelRatio 换算)CSS px + DPR
资源密度drawable-{桶} / mipmap-asset 变体目录 1.0x/2.0x/3.0xsrcset / media queries
缺失行为按 density 缩放(可能放大)解码不随 density 放大;绘制可能拉伸低清源浏览器按 DPR 缩放
文字单位sptextScaleFactor / TextScalerrem / 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.png
dart
// 读取当前设备倍率(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。

常见误配、事故后果与排障 ​

  1. 大图放低桶(如 480px 放 mdpi):3x 设备内存 ×9、显示模糊。修法:放 xxhdpi / nodpi,配合 inSampleSize 降采样。
  2. 每个桶各放一份大图:APK 体积成倍翻。修法:大图只放 nodpi 或一个高桶。
  3. 把图标放 nodpi:失去 dp 物理尺寸语义——要么显示得小(像素没换算),要么 View 拉伸可能糊。修法:图标用矢量或 xxhdpi 桶。
  4. 背景图不降采样直接加载:整图进内存(4K 图约 31.6 MiB+)。修法:inJustDecodeBounds + inSampleSize 按屏幕尺寸解码(见 07/08)。
  5. 把文字也按图片思路一并缩放:系统大字体下炸行、点击区不足。修法:文字用 sp/text scaler,布局和图片资源策略分开治理。
  6. 布局写死 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

复习检查题 ​

  1. 1080p(3x)手机上一个 48dp 的图标,需要多少 px 的位图才“正好”?

    答:48dp × 3 = 144px。提供 144px 的图放 xxhdpi 桶即可精确命中;放 mdpi 桶会被放大 3 倍(内存 ×9 且糊)。

  2. drawable-nodpi 和 drawable-xxhdpi 的差别是什么?

    答:nodpi 不参与 density 缩放,解码尺寸恒等于原图像素,适合“像素即真理”的照片/背景;xxhdpi 参与缩放,在 3x 设备精确命中、低密度设备向下缩,适合需要 dp 物理尺寸语义的 UI 图标。

  3. Flutter 只放一张 1x 图,在高分屏(3x)上会怎样?

    答:按 devicePixelRatio 找不到 2x/3x 变体时用原图、解码不随 density 放大——内存不被 density 机制撑大,但 绘制时 GPU 可能拉伸低清源图,可能略糊;想要清晰需按像素比放变体或用 flutter_svg。

  4. 为什么“图标清晰了”不代表“页面适配就做好了”?

    答:因为页面是否适配好还取决于文字可读性、点击区、安全区和布局结构。图片资源只解决“显示得清不清楚”和“内存炸不炸”,不解决系统栏遮挡或大字体炸行。

  5. 为什么 100dp 在不同密度屏上视觉宽度趋于一致?占屏比例为何仍会因逻辑宽不同而变化?

    答:1dp ≡ 1/160 英寸(理想锚点),所以 100dp 在理想锚点下恒约 0.625 英寸 ≈ 1.59cm,与设备密度无关(仅个别虚报 DPI 的机型会轻微偏离)。但 dp 不归一化占屏比例——360dp 宽与 411dp 宽手机上同一 100dp 占屏比例不同。要让设计稿比例在各手机上一致,需 02 的 375 分份 / 断点换布局,而不是在 01 里只靠换桶解决。

  6. 1dp / 1 逻辑像素 / 1pt 分别怎么换算成物理像素?为什么说三者“近似相等”?HDPI 与 2×/3× 是什么关系?

    答:公式同构 px = 逻辑单位 × 倍率:Android dp × (设备dpi/160)、Flutter 逻辑像素 × devicePixelRatio、iOS pt × scale。近似相等是因为三者的逻辑单位都是“密度锚点”——1dp 严格 = 1/160″(0.1588mm)、1pt ≈ 1/163″(0.1558mm,差约 2%)、Flutter 跟随宿主。HDPI 是 Android 密度桶的一档(240dpi = 1.5x,1dp = 1.5px),与 iOS @2x/@3x、Flutter 2.0x/3.0x 变体目录是“同一件事的不同叫法”——都是倍率:既用于布局换算物理像素(100dp @2x=200px),也用于资源切图档位(48dp 图标 @2x 需 96px、@3x 需 144px)。

  7. 自适应图标(AdaptiveIconDrawable)为什么能适应不同厂商的桌面形状?它和 9-patch 有什么区别?

    答:自适应图标由「前景层(主体图形,放在安全区内)+ 背景层 + 设备蒙版」三层合成,蒙版形状由厂商设备决定(圆 / 圆角方形 / 泪滴),运行时系统裁剪——所以主体在任何形状下都不被裁掉。9-patch 解决的是“位图整体拉伸会变形”(用黑边定义可拉伸区 / 内容区),自适应图标解决的是“图标被不同形状蒙版裁切”——用途完全不同:前者用于按钮背景 / 气泡等可拉伸图,后者用于启动图标。

  8. 把大图显示尺寸缩小(例如 Image 设 width: 100),为什么内存往往不会跟着下降?

    答:内存由解码进 RAM 的位图像素决定,不是由屏幕上画多大决定。默认路径往往是「先按全图或桶规则全量解码 → 再 GPU 缩放到显示框」。只改 width/height 或 BoxFit 只影响绘制阶段,若未同步 inSampleSize / cacheWidth / Glide override() / ImageIO 缩略图,解码仍是 4000×3000,内存仍 ≈45.8 MiB。必须让解码像素 ≈ 显示需要的物理像素才能省内存(见第三层「无约束渲染」与 07/08)。

速记 ​

  • 换算:px = dp × (dpi/160);1080p ≈ 360dp 逻辑宽。
  • 三端同构:px = 逻辑单位 × 倍率——Android dp×(dpi/160)、Flutter 逻辑像素×dpr、iOS pt×@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。

站点构建时间:2026/8/24 23:43:17