说到Android屏幕适配,是老生常谈的话题,适配的目的无非就是不同设备UI表现结果要和设计图比例一致。实际适配过程中,面对不同的机型,多样的分辨率,你适配对了吗?是否因为图片位置不对导致应用OOM?本文介绍常用屏幕适配方案宽高限定符和sw限定符,着重介绍sw限定符和图片适配。
穷举市面上所有的手机宽高,生成相应的res文件,如values-1920 × 1080,values-1280 × 720,values-1024 × 600等,使用其中一种分辨率(和UI设计一样)作为基准,编写dimens文件,然后其他所有的分辨率根据基准分辨率计算,生成其对应分辨率的dimens文件,使用时,直接按照ui设计图在xml里面使用对应的dimens值,不同分辨率会使用到其对应的dimens下的值。
这一方案有两个问题:
宽高限定符values计算:
方式1:
先算出底部导航栏的高度和屏幕实际宽高,然后用屏幕实际高度减去导航栏高度
方式2:
通过下面代码获取:
DisplayMetrics mDisplayMetrics = new DisplayMetrics();
getWindowManager().getDefaultDisplay().getMetrics(mDisplayMetrics);
int widthPixels = mDisplayMetrics.widthPixels;
int heightPixels = mDisplayMetrics.heightPixels;
又称最小宽度限定符,Android会识别屏幕可用高度和宽度中最小尺寸的dp值(一般就是手机宽的dp值),然后根据识别到的结果在资源文件中使用对应的资源文件,如果没有找到对应的,就会向下找,最后使用默认的。比如手机sw=360dp,会优先使用values-sw360dp,如果没有values-360dp,只有values350dp,会使用values-350dp下的文件,如果没有比350dp和更小的,会使用默认的配置(values下)。
这一方案和宽高限定符相比,更容易命中资源,而且就算没有该资源,也会使用接近的,ui效果差别不会太大,所以这一方案使用比较广泛。
默认以竖屏情况下进行介绍,如有区分横竖屏适配会特别说明
布局适配即根据适配方案之一加载指定布局
layout 默认目录
layout-land 横屏
layout-port 竖屏
正常情况下,默认目录是竖屏,单独增加layout-land适配横屏,反之亦然
android3.0之前,适配指定分辨率,将layout文件夹做如下命名:
layout-1024 × 768
layout-1024 × 600
layout-1280 × 768
android3.0以后,需将高度减去底部导航栏像素的高度,这里假设底部导航栏高度是48px:
layout-976 × 768
layout-976 × 600
layout-1232 × 768
如果要区分横竖屏适配(android3.0以后),目录名加上land (横屏)或port(竖屏)
横屏适配:layout-land-1024 × 720
竖屏适配:layout-port-976 × 768
命名如下:
layout-sw360dp
layout-sw392dp
layout-sw411dp
如果要区分横竖屏适配,目录名加上land (横屏)或port(竖屏)
layout-sw360dp-land
layout-sw360dp-port
此外还有一种目录layout-w360dp,与layout-sw360dp的区别,举例说明
这里的sw代表smallwidth的意思,当你的屏幕的绝对宽度大于360dp时,屏幕就会自动调用layout-sw360dp文件夹里面的布局。
注意:这里的绝对宽度是指手机的实际宽度,与手机横竖屏无关。sw最小宽度是指屏幕宽高的较小值,每个屏幕都是固定的,不会随着屏幕横向纵向改变而改变。
当你的屏幕的相对宽度大于360dp时,屏幕就会自动调用layout-w360dp文件夹里面的布局。
注意:这里的相对宽度是指手机相对放置的宽度;即当手机竖屏时,为较小边的长度;当手机横屏时,为较长边的长度。当屏幕横向纵向切换时,屏幕的宽度是变化的,以变化后的宽度来与原来的宽度相比,看是否使用此资源文件下的资源。
与layout-w360dp的使用一样,只是这里指的是相对的高度。但这种方式很少使用,因为屏幕在纵向上通常能够滚动导致长度变化,不像宽度那样基本固定,因为这个方法灵活性不是很好,google 官方文档建议尽量少使用这种方式。
这里的sw、w、h的 dpi 值计算方式如下
DisplayMetrics metrics = getResources().getDisplayMetrics();
int widthDpi = (int) (metrics.widthPixels / metrics.density);
int heightDpi = (int) (metrics.heightPixels / metrics.density);
sw: 取widthDpi 和heightDpi 的较小值
w: widthDpi
h: heightDpi
values目录可放的资源比较多,比如dimen,color,style,国际化语言。values目录结构可以复杂到
values-port-xhpdi-1280 × 768-4,实际不用这么精确适配
上面说了布局的限定符,正常情况使用长度的限定符多点,流程同上面。
选择其中一种设备作为基准适配,然后以基准设备在等比生成其它设备的sw值
参考单位:
单位 | 描述 |
---|---|
px (pixels) | 像素,就是屏幕上实际的像素点单位 |
dip或者dp (device independent pixels) | 设备独立像素, 与设备屏幕有关 |
sp (scaled pixels — best for text size) | 类似dp, 主要处理字体的大小 |
dpi (dots of per inch) | 屏幕像素密度,每英尺(对角线长度)的点数 |
ppi(pixels of per inch) | 每英尺的像素点数,代表着像素和真是大小的关系 |
注意:dpi不等于ppi(ppi = √(宽^2 + 高^2)/ 屏幕尺寸),dpi是写入系统配置文件中的,可以通过代码获取
int densityDpi = getResources().getDisplayMetrics().densityDpi;
参考计算公式:
px = density * dp;
density = dpi / 160;
px = dp * (dpi / 160);
而sw-xxxx-dp的计算公式是 :
sw *160/dpi
sw为宽高的较小边
1dp=1dp
1dp=411/360*1dp=1.14dp
1dp=480/360*1dp=1.33dp
1dp=480/360*1dp=1.33dp
1dp=320/360*1dp=0.88dp
1dp=480/360*1dp=1.33dp
备注:设备参数来源模拟器
|values目录| dp值|
-------- | ------------- | -------------
|values(默认目录和基准一样) | 1dp=1dp
| values-sw320dp | 1dp=0.88dp
| values-sw360dp(基准sw) | 1dp=1dp
|values-sw411dp | 1dp=1.14dp
| values-sw480dp | 1dp=1.33dp
5.最后设计图控件标注多少dp,就选多少dp。
6.测试验证:
当你标注控件是宽高250dp×250dp,运行在不同设备表现的宽度占比是接近的
由于设备dp值最终要换算成px值(根据公式px= dp * density
),故上面基准设备和5种设备250dp最终值为
通过上面发现,到最后得出的比例都是在 0.6927 左右,所以屏幕适配完成了等比例缩放,误差范围内是可以接受的,如果差别很大,说明你适配有问题。上面0.6927和0.6875不同是因为计算swdp时,舍去了小数部分,比如设备1本来是411.4285714285714,取值411dp,是因为小于或等于 411.4285714285714 dp 的 values-swdp会被系统匹配匹配到,大于的无法匹配。
补充Sw代码中计算方式:
Sw计算方式1:
/**
* 获取SmallestWidthDP
* @param context
* @return
*/
public static float getSmallestWidthDP(Context context) {
DisplayMetrics dm = context.getResources().getDisplayMetrics();
int heightPixels =getScreenHeight(context);
int widthPixels = getScreenWidth(context);
float density = dm.density;
float heightDP = heightPixels / density;
float widthDP = widthPixels / density;
float smallestWidthDP;
if (widthDP < heightDP) {
smallestWidthDP = widthDP;
} else {
smallestWidthDP = heightDP;
}
return smallestWidthDP;
}
/**
* 获取屏幕宽度
*
* @param context Context
* @return 屏幕宽度(px)
*/
public static int getScreenWidth(Context context) {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
Point point = new Point();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
wm.getDefaultDisplay().getRealSize(point);
} else {
wm.getDefaultDisplay().getSize(point);
}
return point.x;
}
/**
* 获取屏幕高度
*
* @param context Context
* @return 屏幕高度(px)
*/
public static int getScreenHeight(Context context) {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
Point point = new Point();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
wm.getDefaultDisplay().getRealSize(point);
} else {
wm.getDefaultDisplay().getSize(point);
}
return point.y;
}
Sw计算方式2:
int smallestWidthDP2 = (Math.min(heightPixels,widthPixels)) * 160 / dm.densityDpi;
Sw计算方式3:
int smallestScreenWidthDp3 =context.getResources().getConfiguration().smallestScreenWidthDp;
批量生成values-sw目录
上面说了怎么计算sw值,实际适配要生成不同sw值下的不dimen长度,有两种方法:
package com.sjl.lib.screenadaptation;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* sw生成工具类
*
* @author Kelly
* @version 1.0.0
* @filename MakeDpXml
* @time 2020/11/30 14:33
* @copyright(C) 2020 song
*/
public class MakeDpXml {
/**
* 基值,单位dp
*/
private static int baseSw = 1080;
private static List<Integer> sw = Arrays.asList(360,392,411,1080,1440,2160);
private static int decimals = 4;
private static int scale = 1;
private static String filename = "dimens.xml";
public static void main(String[] args) {
//自定义参数
baseSw = 1080;
decimals = 4;
scale = 1;
//模板文件,文件存储F:/layout下,也可以自己定义
filename = "dimens.xml";
create();
System.out.println("MakeDp完毕");
}
public static void create() {
//以此文件夹下的dimens.xml文件内容为初始值参照
File file = new File("F:/layout",filename);
List<String> lines = new ArrayList<>();
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader(file));
String tempString;
while ((tempString = reader.readLine()) != null) {
lines.add(tempString);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (reader != null) {
try {
reader.close();
} catch (Exception ex) {
}
}
}
int lineSize = lines.size();
if (lineSize == 0) {
return;
}
Map<Integer, String> resultContent = new HashMap<>();
for (int j = 0; j < sw.size(); j++) {
Integer tempSw = sw.get(j);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < lineSize; i++) {
String xmlStrLine = lines.get(i);
if (xmlStrLine.contains("")) {
String start = xmlStrLine.substring(0, xmlStrLine.indexOf(">") + 1);
String end = xmlStrLine.substring(xmlStrLine.lastIndexOf("<") - 2);
//截取 标签内的内容,从>右括号开始,到左括号减2,取得配置的数字
String text = xmlStrLine.substring(xmlStrLine.indexOf(">") + 1,
xmlStrLine.indexOf("") - 2);
if (text.startsWith("@dimen")) {
sb.append(xmlStrLine).append("\r\n");
continue;
}
Float num = Float.parseFloat(text);
//根据不同的尺寸,计算新的值,拼接新的字符串,并且结尾处换行。
float value = (float) tempSw / baseSw * num;
if (tempSw != baseSw){
value = value / scale;
}
sb.append(start).append(String.format("%."+decimals+"f", value)).append(end).append("\r\n");
} else {
sb.append(xmlStrLine).append("\r\n");
}
}
resultContent.put(tempSw, sb.toString());
}
//写入文件
for (int i = 0; i < sw.size(); i++) {
Integer tempSw = sw.get(i);
String s = resultContent.get(tempSw);
File dir = new File("F:/layout/values-sw" + tempSw + "dp",filename);
if (!dir.getParentFile().exists()) {
dir.getParentFile().mkdirs();
}
MakeXmlUtils.writeFile(dir.getAbsolutePath(), s);
}
}
}
MakeXmlUtils:
/**
* 写入文件
*/
public static void writeFile(String file, String text) {
PrintWriter out = null;
try {
out = new PrintWriter(new BufferedWriter(new FileWriter(file)));
out.println(text);
} catch (IOException e) {
e.printStackTrace();
} finally {
if (out != null) {
out.close();
}
}
}
上面说sw长度适配,如果控件是图片,根据设计图指定了控件宽高,如何选择哪个密度的图片呢?
操作步骤:
DisplayMetrics dm = getResources().getDisplayMetrics();
int densityDpi = dm.densityDpi;
目录 | 对应密度 | 设备密度 | logo建议尺寸 | 密度区间 |
---|---|---|---|---|
ldpi | 120dpi | 0.75 | 36*36 | 0-120 |
mdpi | 160dpi | 1 | 48*48 | 120dpi~160dpi |
hdpi | 240dpi | 1.5 | 72*72 | 160-240 |
xhdpi | 320dpi | 2 | 96*96 | 240-320 |
xxhdpi | 480dpi | 3 | 144*144 | 320-480 |
xxxhdpi | 640dpi | 4 | 192*192 | 480-640 |
根据设备实际dpi选择图片下载存放即可,一般选择一种基准密度,如360dpi,如xxhdpi,然后运行在不同的设备,控件显示长度最终计算出的宽度占一致,上面已经说明。
但是,运行到不同dpi的设备时,占用内存是不一样,与控件显示尺寸无关,别妄想修改控件显示尺寸降低应用内存。当且仅当设备密度区间是320dp~480dpi的时候,匹配到xxhdpi时最佳(指清晰度最好,内存占用适当),其它设备密度图片内存减小或增多,图片出现不同程度模糊。
上面说了dimen适配长度是宽度占比一样的,但图片占用内存不一样,图片占用内存大小与图片本身的分辨率、像素存储格式、图片所在drawable 文件夹和设备dpi有关。
像素占用内存受Bitmap色彩的存储模式影响:
举例子之前,先了解图片实际宽高计算公式
新图的宽度 = 原图宽度 * (设备的 dpi / 目录对应的 dpi )
新图的高度 = 原图高度 * (设备的 dpi / 目录对应的 dpi )
根据上面所得的宽高(不是显示的尺寸android:layout_width,android:layout_height
)所知,图片占用内存大小计算公式:
图片占用内存大小=宽x高 *一个像素所占的内存字节大小
实际并不是直接取原图宽高计算,严格来说是以图片以bitmap的形式存在内存中的实际尺寸。所以上面
图片占用内存大小=bitmap宽xbitmap高 *一个像素所占的内存字节大小
而
bitmap 宽 = 原图的宽 * (设备的 dpi / 目录对应的 dpi)
bitmap 高 = 原图的高 * (设备的 dpi / 目录对应的 dpi)
或者
图片占用内存大小 = 原图宽 * (inTargetDensity / inDensity) * 原图高 * (inTargetDensity / inDensity) * 每个像素占用的字节数
inDensity:表示目标图片的dpi(放在哪个资源文件夹下),即目录对应的 dpi
inTargetDensity:表示目标屏幕的dpi,即设备的 dpi
所以你可以发现inDensity和inTargetDensity会对Bitmap的宽高进行拉伸,进而改变Bitmap占用内存的大小
举例子:
假设你设备的基准设备dpi是360dp,密度3,对应图片目录是xxhdpi,分辨率为1080 × 1920,sw为sw360,有这么一个图片的宽高为250px ×250px,假设你放错了图片目录(不是mdpi,正常放在xxhdpi目录),图片所占用的内存大小是不一样的。
存放目录 | 宽度计算 | 内存变化 |
---|---|---|
ldpi | 360/120*250 | 变大 |
mdpi | 360/160*250 | 变大 |
xxdpi | 360/360*250 | 正常 |
xxdpi | 360/640*250 | 变小 |
同理高度亦然。
通过上面可知,** 同一设备的同一张图片不同图片目录下,占用内存不一样**。
计算参考:
新图的宽度 = 原图宽度 * (设备的 dpi / 目录对应的 dpi )
通过上面可知**,不同设备的同一张图片在同一个目录下,设备dpi不同占用内存不一样**。
总之,平时我们都是一套图片适配,但是如果一套图片适配,出现细微差异,需要针对不同设备dpi单独适配图片。需要注意图片目录存储位置不要差异过大,否则容易出现oom,图片模糊等问题。
下面数据是每种设备的sw值,可以选择其中一个设备作为基准值适配,然后以基准设备在等比生成其它设备的sw值,适配过程同上面sw dimen适配流程
终端设备样例:
尺寸 | 屏幕方向 | 分辨率 | 密度 | 对应目录 dp和px关系 | sw | 蓝湖预览分辨率自定义 |
---|---|---|---|---|---|---|
8 | 竖屏 | 1280 × 800 | 1 | mdpi | 1:1 | sw800 |
10.1 | 横屏 | 1280 × 800 | 1 | mdpi | 1:1 | sw800 |
55 | 竖屏 | 1920 × 1080 | 1 | mdpi | 1:1 | sw1080 |
21 | 竖屏 | 1920× 1080 | 1 | mdpi | 1:1 | sw1080 |
17 | 横屏 | 1280 × 1024 | 1 | mdpi | 1:1 | sw1024 |
手机:
尺寸 | 屏幕方向 | 分辨率 | 密度 | 对应目录 dp和px关系 | sw | 蓝湖预览分辨率自定义 | 描述 |
---|---|---|---|---|---|---|---|
5.2 | 竖屏 | 1920 × 1080 | 3 | xxhdpi | 1:3 | sw360 | 360*640 |
6.4 | 竖屏 | 2340 × 1080 | 3 | xxhdpi | 1:3 | sw360 | 360*780 |
屏幕方向判断:
If (this.getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) {
//竖屏
System.out.println("竖屏");
} else {
//横屏
System.out.println("横屏");
}
sw适配的缺点
当以不同的基准sw进行适配时,在不同sw的设备上运行,可能存在长度差异,从而导致某些情况下最终显示差异
情况1:sw360dp基准(密度是3),如下图:
以sw360dp基准去适配sw1080dp设备(密度是1),发现在sw1080dp设备,最终px是整数,159*1=159,不存在差异问题
情况2:sw1080dp基准(密度是1),如下图:
以sw1080dp基准去适配sw360dp设备(密度是3),发现在sw360dp设备,最终px是小数,17.6667*3=53.0001,存在差异问题
当采用sw适配时,代码中禁止使用下面这种转换方法获取dp或px,因为这脱离了sw适配体现,会导致误差,建议使用getDimension、getDimensionPixelOffset 、getDimensionPixelSize获取
/**
* dip转px
* @param context
* @param dipValue
* @return
*/
public static int dip2px(Context context, float dipValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (dipValue * scale + 0.5f);
}
/**
* px转dip
* @param context
* @param pxValue
* @return
*/
public static int px2dip(Context context, float pxValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (pxValue / scale + 0.5f);
}
采用sw限定符适配,难免有时候在Java或Kotlin代码中引用dimen.xml的的dp值
主要有以下三种方法:
getDimension
返回float值getDimensionPixelOffset
将float强转为int值返回getDimensionPixelSize
将float值四舍五入成int值返回上面三种方法返回值,以sw xml文件的长度单位为准:
根据上面可知,当你使用dp值作为字体大小设置时需要注意放大问题,即xml设置字体大小和代码中表现结果是不一样的:
xml设置字体20dp
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
android:textSize="@dimen/dp_20"
/>
Java代码中设置20dp
float size = context.getResources().getDimension(R.dimen.dp_20);
TextView textView = new TextView(context);
textView.setTextSize(size);
上面字体结果值会比xml的大(设备密度非160dpi,即不是1px不等于1dp的情况),导致显示不达预期。原因是上面已经说明,当给定值是dp,则需乘以屏幕密度再返回(转成px值),而
setTextSize的默认单位是sp,使用sp单位时,会乘多缩放因子scaledDensity,故字体放大了
备注:无论是sp还是dp最终都转成px,getResources().getDisplayMetrics().scaledDensity
和getResources().getDisplayMetrics().density
一般情况下是相同的
为了兼容其它设备,正确获取dp值如下:
float size = context.getResources().getDimension(R.dimen.dp_20)/ context.getResources().getDisplayMetrics().density;
TextView textView = new TextView(context);
textView.setTextSize(size);
//或者
setTextSize(TypedValue.COMPLEX_UNIT_PX,getResources().getDimensionPixelSize(R.dimen.dp_20));
这样设置才是是正确的值,显示效果达到预期。
今日头条适配方案,通过修改系统density达到适配目的。Android中的尺寸,不管使用的是什么单位,系统最终都会转换成px使用,而
px = dp * density
而系统使用的density
值,是 DisplayMetrics 中的成员变量,而 DisplayMetrics
实例通过 Resources#getDisplayMetrics
可以获得,而Resouces
通过Activity
或者Application
的Context
获得。也就是说所有的dp和px的转换都是通过 DisplayMetrics 中相关的值来计算的,所以只需要修改DisplayMetrics
中的density
值,就可以完成dp适配。sp的适配也类似,只是多了一个缩放因子,让用户可以改变字体大小
public class ScreenAdpt {
/** 设计图宽度dp */
private static final float width = 360;
private static float textDensity = 0;
private static float textScaledDensity = 0;
/**
* @param activity
*/
public static void setCustomDensity(@NonNull final Activity activity) {
final Application application = activity.getApplication();
final DisplayMetrics displayMetrics = application.getResources().getDisplayMetrics();
if (textDensity == 0) {
textDensity = displayMetrics.density;
textScaledDensity = displayMetrics.scaledDensity;
application.registerComponentCallbacks(new ComponentCallbacks() {
@Override
public void onConfigurationChanged(Configuration configuration) {
if (configuration != null && configuration.fontScale > 0) {
textScaledDensity = application.getResources().getDisplayMetrics().scaledDensity;
}
}
@Override
public void onLowMemory() {
}
});
}
final float targetDensity = displayMetrics.widthPixels / width;
final float targetScaledDensity = targetDensity * (textScaledDensity / textDensity);
final int targetDpi = (int) (160 * targetDensity);
displayMetrics.density = targetDensity;
displayMetrics.scaledDensity = targetScaledDensity;
displayMetrics.densityDpi = targetDpi;
final DisplayMetrics activityDisplayMetrics = activity.getResources().getDisplayMetrics();
activityDisplayMetrics.density = targetDensity;
activityDisplayMetrics.scaledDensity = targetScaledDensity;
activityDisplayMetrics.densityDpi = targetDpi;
}
}
总之,smallestWidth 限定符屏幕适配方案的情况下,不同设备 View 与屏幕宽度的比例和设计图中的比例一致。适配需要注意是选择其中一种设备作为基准适配,然后以基准设备(最小宽度基准值 )在等比生成其它设备的sw值,不然容易出问题,UI设计师一般没有这种意识,多数基于某种分辨率出图,当你选择sw限定符适配时,多数需要自定义分辨率预览(指蓝湖、慕客等工具),不然容易出现选错长度和图片大小,导致一系列问题。上面如有错误,请指正。