做 Godot 安卓导出的时候,最绕不开的单位坑就是 dp 和 px。Godot 的界面、控件、字体大小,底层用的几乎都是像素(px);而 Android 系统给原生控件、布局参数、设计规范用的却是密度无关像素(dp)。如果你直接在 Godot 里按像素写死一个按钮宽度,换一台高分屏手机,按钮要么小到点不中,要么大得离谱,因为在不同屏幕上 dp 和 px 的比例是变化的。这篇文章就从这个“为什么要换算”的真实痛点开始,把 dp 转 px 的公式、Godot 4 里的取数方式、常用封装函数,以及和拉伸模式的配合关系一次讲清楚。想给 Android 做 UI 适配的 Godot 开发者,尤其适合从头到尾读一遍,很多坑提前知道比踩完再修省事得多。
1. 这个单位问题是怎么冒出来的
1.1 一次 Android 真机调试引发的困惑
有一次我把一个 Godot 项目导出到一台 1080P 的安卓手机上,在编辑器里预览一切正常。结果真机一跑,测试同学反馈说按钮太小。我第一反应是触摸区域的问题,点开调试面板回到 PC 端看,却又没有复现。后来又换了一台 2K 屏手机,问题更明显,界面元素明显比第一台小了一大圈。
那时我才意识到,问题不在代码逻辑,而在单位体系。Godot 的默认项目配置里,viewport 宽高是像素值。编辑器预览窗口通常也就一千多像素宽,和真机动辄两三千像素的物理分辨率一比,同一个 50px 按钮在高分屏上占的比例就会完全不同。如果设计时没有考虑 Android 的屏幕密度体系,而是直接以像素为单位写死控件尺寸,那不同设备上自然会呈现出差异。
后来我把按钮宽度从“写死 50px”改成“先换算成 50dp,再根据屏幕密度转成对应像素”,问题才真正解决。这也是这篇文章想讲的核心:在 Godot 里接到 Android 的 dp 需求时,到底该怎么正确地转成 px。很多人只把 dp 当成一个和 px 差不多的数字,但它在不同设备上是变量,不换算就会出问题。
1.2 dp、px、dpi 三个单位的真实关系
先统一一下概念。px 是物理像素,一个屏幕点对应一个像素;dpi 是每英寸包含的像素点,它描述的是屏幕的像素密度;dp,也叫 dip,是 density-independent pixel,密度无关像素。
dp 的巧妙之处在于,它是一个“锚定值”。Android 把 160dpi 作为基准密度,规定“在 160dpi 的屏幕上,1dp 等于 1px”。在这个基准之上,如果屏幕密度变成 320dpi,那么 1dp 就等于 2px。这样同一个 48dp 的按钮,在低分屏和高分屏上物理尺寸会保持在同一个视觉量级,看起来差不多大。
这就解释了为什么 Android 原生应用都喜欢用 dp 来定义控件尺寸:因为它帮开发者屏蔽了屏幕密度差异。而 Godot 没有直接提供“dp”这个单位,开发者在程序里给 Control 设置 custom_minimum_size、给 Label 设置 font_size 时,传入的都是像素值。所以当你从 Android 设计稿里拿到一个 dp 尺寸时,不能直接填进去,得先把 dp 换算成当前设备上对应的 px。
这里要特别提醒:Godot 内部其实有一套自己的“逻辑坐标”体系,如果不加任何处理,编辑器里的坐标和真机像素并不总是一一对应。后面我会专门讲它和拉伸模式的关系,先记住一个结论:dp 到 px 的换算公式是整个适配工作的基础,理解和验证都基于这条线展开。
2. 换算公式与核心原理
2.1 从定义推出的标准公式
既然 1dp 在 160dpi 屏幕上是 1px,那换个密度自然也成比例。dp 转 px 的公式就是:
px = dp × (dpi / 160)这里 dpi 具体该取哪个值,在 Android 体系里有讲究。它一般不是屏幕物理 PPI,而是系统提供的 densityDpi,和资源桶(density bucket)相关。比如某台设备物理 PPI 可能是 403,但系统上报的 densityDpi 可能是 420 或 440。公式里要用后者,因为它是 Android 做尺寸换算时实际采用的那个值。
放到 Godot 4 里,这个 dpi 可以通过DisplayServer.screen_get_dpi()拿。如果返回的值接近 160、240、320、480 这类档位,那基本就是 Android 上报的 densityDpi;如果返回的是 72 这种默认值,那需要走兜底逻辑。这段代码实现我放在后面详细讲。
2.2 常见密度档位对照表
Android 官方把常见屏幕密度分成了几档:
| 密度档位 | 代表 dpi | 密度倍率(density) | 1dp 对应 px |
|---|---|---|---|
| ldpi | 120 | 0.75 | 0.75px |
| mdpi | 160 | 1.0 | 1px |
| hdpi | 240 | 1.5 | 1.5px |
| xhdpi | 320 | 2.0 | 2px |
| xxhdpi | 480 | 3.0 | 3px |
| xxxhdpi | 640 | 4.0 | 4px |
你可以直接把“密度倍率”当成 dp 转 px 的系数。这张表也可以用来检查 Godot 拿到的 DPI 是否合理。比如一台常见中端机上报 480,那 48dp 换算后就是 144px;一台 2.75 倍率的旗舰机,48dp 大约是 132px,这基本符合 Android 原生控件的物理大小。
注意:不要把这里的 density 和 CSS 里的
devicePixelRatio完全混为一谈。Android 的 densityDpi 是从系统 DisplayMetrics 里拿到的规范值,它和物理 PPI 可以是两套数字。换算时优先用系统上报的 densityDpi。
2.3 两个真实设备的换算示例
举个具体例子。假设现在要在两个设备上做一个 48dp 的按钮,这是 Android Material 规范里比较合理的触摸高度下限。
第一台设备上报 densityDpi 为 320,density 为 2.0。代入公式:
px = 48 × (320 / 160) = 96px第二台设备上报 densityDpi 为 420,density 为 2.625。同样套公式:
px = 48 × (420 / 160) = 126px两台设备的按钮物理尺寸其实差不多,因为 dp 本身就是按密度等比换算的。如果一开始直接把按钮设成 96px,那在第二台设备上看起来就只有约 36.6dp,明显偏小。这也是很多 Godot 项目在安卓上“UI 忽大忽小”的根源:不是逻辑写错,而是单位没换算。
3. 在 Godot 4 里用 GDScript 完成 dp 转 px
3.1 用 DisplayServer 拿系统 DPI
Godot 4 把显示相关信息统一放到了DisplayServer单例里。取 DPI 的方法签名是这样的:
var main_screen_id: int = DisplayServer.get_main_screen_id() var dpi: int = DisplayServer.screen_get_dpi(main_screen_id) print("当前屏幕 DPI: ", dpi)如果你的项目只跑单屏安卓设备,直接传DisplayServer.get_main_screen_id()就行。如果项目未来要上桌面多显示器,最好把 screen id 作为参数,避免不同显示器 DPI 不同带来的问题。
有一点需要确认:screen_get_dpi()在 Android 上是否可靠,取决于引擎版本和设备上报。实测中,主流机型返回的数值落在 160 到 640 的区间内,和 densityDpi 基本一致。但极少数 ROM 会返回物理 PPI 或异常默认值,所以工程里最好做一层防御。
3.2 封装一个全局可复用的换算工具
我不建议在业务代码里到处写这个公式,很容易把常数写散。比较稳妥的方式是做一个 autoload 单例,比如名字叫ScreenAdapter,统一管理 DPI 获取和单位换算。
extends Node const DP_BASELINE := 160.0 var _screen_dpi := -1 func _ready() -> void: refresh_dpi() func refresh_dpi() -> void: var screen_id := DisplayServer.get_main_screen_id() var reported_dpi := DisplayServer.screen_get_dpi(screen_id) if reported_dpi > 0: _screen_dpi = reported_dpi else: _screen_dpi = get_fallback_dpi() func get_fallback_dpi() -> int: # 桌面预览时很多平台会返回 72,这里回退到常见 mdpi 档位, # 保证编辑器里至少能看到差不多的比例。 return 160 func dp2px(dp_value: float) -> float: var density := _screen_dpi / DP_BASELINE return dp_value * density func dp2pxi(dp_value: float) -> int: # UI 用整数做最终布局更安全,避免半像素渲染。 return int(round(dp2px(dp_value)))这样写的好处是,所有控件尺寸、间距、字体大小只和“dp 值”打交道,逻辑上非常干净。后续如果要给别人维护,看到dp2px(48),也能知道是想做一个 48dp 的控件。而dp2pxi这个变体是给控件尺寸、边距这类更倾向整数值的场景用的。
如果你不想做 autoload,也可以把这两个函数放到一个静态工具类里。但我在团队项目里更推荐 autoload,因为 DPI 可以做成缓存,避免每个实例都重复查询。真机上 DisplayServer 的查询并不昂贵,但集中管理对排查问题更友好。
3.3 拿不到 DPI 时的兜底逻辑
真机测试时会发现,screen_get_dpi()并不是在所有平台都稳定。比如某些 Linux 桌面环境、模拟器、早期 Godot 4.x 小版本,都可能返回 0 或 72。如果直接拿这个数参与换算,结果就全错了。
我的兜底策略分三层。
第一层,刷新时判断返回值是否大于 0,如果无效就用项目自定义的默认密度。我通常会放一个 ProjectSettings 自定义项,比如custom/screen/density_override,方便在特殊机型上手动指定 density。这样不需要改代码,只要在项目设置里填数值即可。
func get_fallback_dpi() -> int: var override := ProjectSettings.get_setting("custom/screen/density_override", 0) if int(override) > 0: return int(override) return 160第二层,如果还想更稳,可以读取屏幕物理尺寸和像素尺寸,算出一个近似的 DPI。Godot 里DisplayServer.screen_get_size()返回像素尺寸,物理尺寸可以由项目设置里的窗口物理尺寸字段参与计算。但这条路在不同平台的表现不太一致,一般只作为二次参考,不会作为主方案。
第三层,测试阶段在 Android 上可以直接用adb shell wm density查看设备当前上报密度,再和 Godot 打印出来的 DPI 对比。如果两个值差得远,就可以判断是引擎返回值异常还是设备的问题。把异常值记录在 issue 里,比盲目修代码更高效。
提示:模拟器里的 densityDpi 经常被设置成 440 或 560,不代表所有真机都会这样。适配测试优先用真机,至少覆盖 1.5 倍率、2.0 倍率、3.0 倍率三档。
4. 把换算结果落到 UI 与项目设置中
4.1 控件尺寸与触摸目标
拿到 dp2px 之后,最常见的落点就是 Control 的最小尺寸。以按钮为例:
var touch_dp := 48.0 button.custom_minimum_size = Vector2( ScreenAdapter.dp2pxi(touch_dp), ScreenAdapter.dp2pxi(touch_dp) )这样按钮在 320dpi 屏幕上会是 96px,在 480dpi 屏幕上会是 144px,但物理大小都在 48dp 左右,摸起来会比较跟手。如果项目里的交互控件是自定义绘制,也可以把点击判定区域单独做换算,不一定非得把整个 Control 放大。
需要注意,当按钮尺寸扩大后,它和旁边控件的间距也要跟着换算,否则按钮变大了,间距还是原来的像素值,整个布局会显得比例失衡。间距、边距、内边距最好都使用同一套 dp 换算,不要混用两套单位。我在项目里会提前定义好一批常用的 dp 常量,比如DP_4、DP_8、DP_12、DP_16、DP_48,业务代码直接引用,可读性也更好。
4.2 字体大小、间距、分隔线
字号是另一个高频场景。Android 的 sp 在文本场景下和 dp 类似,但会受系统字体缩放影响。如果你只在 Godot 里处理 dp 到 px,可以先把字号按 dp 换算,至少保证默认情况下不同屏幕比例一致。
label.add_theme_font_size_override("font_size", ScreenAdapter.dp2pxi(14))之所以用add_theme_font_size_override,是因为它只影响当前控件,不会污染全局主题,适合局部微调。如果整棵树都要统一,直接在 Theme 的默认字体大小里设置会更省事,具体取决于你的项目结构。
分隔线、边框宽度这类“细线条”,也建议走同一套换算。比如 1dp 的分隔线在 2.0 倍率设备上应该画成 2px,在 3.0 倍率设备上画成 3px。如果写死 1px,在高分屏上会细得几乎看不见,颜色再深也压不住视觉效果。
图片和图标资源也会遇到类似问题。如果你的美术资源是按一套固定像素尺寸切图的,那么在 3.0 倍率设备上,一个 24dp 的图标应该显示为 72px。直接在资源加载时按 density 倍率选择不同切图,或者在代码里对 TextureRect 做尺寸换算,都是常见做法。最简单粗暴的方案是只用 SVG 或 Theme 生成资产,但 Godot 对 SVG 的运行时支持毕竟有限,所以多数项目还是会走“多套切图加代码换算”的路子。
4.3 和 ProjectSettings 拉伸模式的关系
这里必须单独讲清楚,因为不少朋友在这里栽过跟头。
Godot 在项目设置里有一组拉伸相关选项,位置在display/window/stretch。默认情况下,viewport 尺寸就是像素尺寸。打开拉伸模式后,Godot 会把场景渲染到一个虚拟坐标空间,再缩放显示到实际窗口上。最常见的配置是:
display/window/stretch/mode = canvas_items display/window/stretch/aspect = expand display/window/stretch/size = 1920x1080在这个模式下,场景里的 1 个逻辑单位不再等同于 1 个物理像素。此时如果你在代码里用 dp2px 把 48dp 换成了 132px,再填给一个 Control,它会被拉伸逻辑再放大一遍,实际渲染出来可能不是你想要的大小,这就是常说的“双重换算”问题。
怎么避免?要看你的项目整体坐标策略。
如果你使用拉伸模式,最好把 viewport 设置成“逻辑 dp 尺寸”思路。比如把一个基准宽度设计成 360 虚拟单位、高度按目标比例放,再让 UI 布局以虚拟单位为准,这时候场景里的虚拟单位其实就承担了“dp”的作用,不需要额外调用 dp2px。这种做法对全屏游戏场景很友好,UI 会跟随分辨率缩放,但不会严格保证所有屏幕上控件的物理 dp 完全一致,因为有 aspect 修正和宽屏适配存在。
如果你更在意 Android 原生控件那种“物理尺寸保持一致”的效果,可以不依赖拉伸模式,直接在代码里拿设备真实像素尺寸布局,所有控件尺寸都用 dp2px。这个方案更贴近原生开发,但 UI 适配的工作量更大,需要自己处理窄屏、折痕屏、不同分辨率下的排布。
折中方案是把display/window/stretch/mode设成disabled,用DisplayServer.screen_get_size()获取真实像素宽高,项目里所有布局尺寸统一走 dp2px,这样和 Android 原生 dp 逻辑最一致。缺点是不再享受 Godot 编辑器中用固定 viewport 预览的便利,需要开发时自行模拟不同分辨率。
我的经验是:游戏类项目优先用拉伸模式,把虚拟单位当成 dp 来设计;偏工具类、应用类项目,更推荐真实像素加 dp2px 的组合。做选择前,先想清楚你的 UI 是“跟着屏幕铺满”优先,还是“物理尺寸恒定”优先。这两个目标在极端宽高比的屏幕上本来就不可兼得。
5. 避坑经验与排查清单
5.1 同一个 dp 为什么在两个设备上不一样
如果同一台设备上运行,有时前后两个界面里同样的控件看起来大小不同,那往往不是换算公式的问题,而是某个控件走了拉伸坐标,某个控件直接用了物理像素。
举个例子,假设开启了canvas_items拉伸,基准分辨率是 1920x1080,但真机屏幕实际是 2400x1080。这时候一个宽度为 96px 的按钮在编辑器里看着正常,在真机上因为横向拉伸,实际像素可能被放大了。另一个按钮尺寸来自 dp2px,直接填了物理像素值,不参与拉伸。两个按钮一对比,就会出现“明明同样 48dp,屏幕上却不一样大”的错觉。
遇到这种情况,优先检查项目每个界面是否统一使用同一种单位来源。最怕一个界面里既用了 viewport 虚拟坐标,又用了物理像素换算值。排查时可以打开 Godot 的调试菜单,看鼠标悬停到控件上时显示的值和实际代码填的值是否一致,能很快定位是哪一层发生了缩放。
5.2 触摸热区太小是忽略换算的典型产物
我见过一个很典型的 case:画面上有个关闭按钮,美术资源画得很大,看起来很显眼,但点击区域只在中心很小的范围。原因是点击判断用了 30px 的矩形,而这 30px 在高分屏上可能只相当于 11dp 左右。手指头戳上去很难命中。
解决思路就是让所有可交互控件的命中区域至少达到 48dp,而不是视觉资源 48px。用代码统一设置 custom_minimum_size,或者给碰撞区域做一次 dp2px 换算。如果视觉上不想把按钮画得太大,可以把按钮本体保持原样,但是外围透明区域计入点击范围。
具体做法可以是在按钮的基类里统一处理:
func _ready() -> void: var hit_expand := ScreenAdapter.dp2pxi(12) custom_minimum_size = Vector2(size.x + hit_expand, size.y + hit_expand)这样视觉资源可以保持原设计,但可点击区域扩大到了 48dp 甚至更大的范围。这个经验特别适合那些“看起来挺大、实际很难点”的 HUD 按钮。
5.3 换算精度:保留浮点还是取整
dp 转 px 时,公式算出来的经常是小数。比如 48dp 在 420dpi 设备上是 126.0px,这还好;但 17dp 在这种设备上就是 44.625px。直接填浮点数到 Control 尺寸,Godot 会处理成半像素渲染,某些情况下会出现发虚的边缘或抗锯齿模糊。
我给的建议是得分场景。控件尺寸、间距、字号这种最终要落到底层绘制的,用四舍五入取整;如果你自己做一套相对布局系统,在计算多个控件相对位置时,中间过程保留浮点,只在最后赋值时取整。这样既能避免半像素,又不至于让布局因为多次取整产生明显误差。
还有一个小技巧:如果你想严格控制对齐,可以让所有换算后的尺寸都以偶数结尾。比如在高分屏上,奇数像素值常常会导致图形边缘出现轻微模糊,虽然现在的 GPU 对亚像素渲染已经处理得不错,但在 icon 和分隔线上仍然会有差别。用int(round(value / 2.0)) * 2可以快速把值规整到偶数,适配效果会更干净。
5.4 再补充一点:能不能直接用物理屏幕尺寸
有些朋友会问:既然 dp 换算这么麻烦,能不能直接在 Godot 项目里设置“物理尺寸”,让 1dp 变成 1mm 之类的单位?从原理上讲,如果能把屏幕的物理 DPI 和实际尺寸都拿到,确实可以做到按毫米布局。Android 系统也会提供类似xdpi/ydpi的信息,但 Godot 的跨平台抽象层在物理尺寸这块支持得不是特别完整,不同厂商的上报还不一致,所以目前更可靠的做法还是走 densityDpi 换算 dp。
如果你只是想在编辑器里模拟不同设备,可以在 ProjectSettings 里找display/window/size/phys_width_mm等物理宽高字段。这个更多是给桌面模拟用的,真机上还是要以运行时的 DPI 为准。
我也试过在 Android 上用 Godot 的 Java 层自定义 Plugin 去拿更精细的 DisplayMetrics,包括xdpi/ydpi、scaledDensity,然后在 GDScript 里调用。这个方案理论上更准确,但成本高,还要维护 Android 插件构建链路。对绝大多数项目来说,用screen_get_dpi()拿 densityDpi 已经足够满足适配需求,不需要为了那一点点精度差距去引入额外的原生代码。
最后再分享一个我实际项目中的体会。最初我也是一行行写死像素值,后来改成全项目统一走一个ScreenAdapter单例之后,适配工作量直线下降。每次拿到新设计稿,我只看里面的 dp 值,不再管具体是多少像素,因为像素值在不同设备上本来就该不同。再往后,如果遇到“某一台设备显示不对”,我也能很快判断是设备 density 上报的问题,还是布局里混用了单位的问题,而不是从头把所有界面翻一遍。这个思路,对任何一个要出安卓包的 Godot 项目都值得提前做,越早统一换算入口,后面适配新机型的时候就越省心。