今天来总结一下 Android 中的 UI 适配,主要从以下几个点来介绍:
- px、dp、dpi、density、sp
- 几种适配方案
- 图片资源适配
px、dp、dpi、density、sp
px :像素单位,比如我们通常说的手机分辨列表 800*400 都是 px 的单位。
dp :设备独立像素,以 dp 为尺寸单位的控件,在不同分辨率和尺寸的手机上代表了不同的真实像素,比如在分辨率较低的手机中,可能 1dp=1px,而在分辨率较高的手机中,可能 1dp=2px,这样的话,一个 96*96 dp 的控件,在不同的手机中就能表现出差不多的大小了。那么这个 dp 是如何计算的呢? 我们都知道一个公式:px = dp * (dpi/160) 系统都是通过这个来判断 px 和 dp 的数学关系。
dpi:像素密度,指的是在系统软件上指定的单位尺寸的像素数量,它往往是写在系统出厂配置文件的一个固定值。
density:设备的逻辑密度,是 dpi 的缩放因子。以 160 dpi 的屏幕为基线,density=dpi/160,所以 px = dp * density。
// 获取 density
getResources().getDisplayMetrics().density
sp:缩放独立像素,全称 scale independent pixel。类似于 dp,一般用于设置字体大小,可以根据用户设置的字体大小偏好来缩放。假如你不要字体大小随系统设置而改变,就直接使用 dp 做单位。
几种适配方案
大体的适配方案有以下几种:
- 根据设计图的尺寸,然后将设计图上标识的 px 尺寸,转化为百分比,为所有的主流屏幕去生成对应百分比的值,每个尺寸都会有一个 values/demins 文件。存在一些问题:产生大量的文件夹,适配不了特殊的尺寸 (必须建立默认的文件夹)。
- 基于 Google 推出的百分比布局 (percent-support-lib),已经很大程度解决了适配的问题。存在一些问题:使用起来比较反人类,因为设计图上标识的都是 px,所以需要去计算百分比,然后这个百分比还是依赖父容器的,设计图可能并不会将每个父容器的尺寸都标识出来,所有很难使用 (当然,有人已经采用自动化的工具去计算了)。还有个问题就是,因为依赖于父容器,导致ScrollView,ListView 等容器内高度无法使用百分比。
- AutoLayout 方案,直接布局中写 px,然后内部有程序会转换。
- 今日屏幕适配方案。
今天主要来讲讲后面两种。
1. AutoLayout
github: https://github.com/hongyangAndroid/AndroidAutoLayout
第一步,添加依赖:
dependencies {
compile 'com.zhy:autolayout:1.4.5'
}
第二步,在 AndroidManifest 中注明你的设计稿
的尺寸:
<meta-data android:name="design_width" android:value="1080" />
<meta-data android:name="design_height" android:value="1920" />
第三步,让你的 Activity 继承自 AutoLayoutActivity:
public class AdapterUIActivity extends AutoLayoutActivity {
}
如果你不希望继承 AutoLayoutActivity
,可以在编写布局文件时,将
- LinearLayout -> AutoLinearLayout
- RelativeLayout -> AutoRelativeLayout
- FrameLayout -> AutoFrameLayout
这样也可以完成适配。
目前支持属性:
- layout_width
- layout_height
- layout_margin(left,top,right,bottom)
- pading(left,top,right,bottom)
- textSize
- maxWidth, minWidth, maxHeight, minHeight
第四步,xml 中,宽高根据设计图标注的 px 写上即可:
<Button
android:layout_width="200px"
android:layout_height="100px" />
可以看看它的源码,AutoLayoutActivity:
public class AutoLayoutActivity extends AppCompatActivity
{
private static final String LAYOUT_LINEARLAYOUT = "LinearLayout";
private static final String LAYOUT_FRAMELAYOUT = "FrameLayout";
private static final String LAYOUT_RELATIVELAYOUT = "RelativeLayout";
@Override
public View onCreateView(String name, Context context, AttributeSet attrs)
{
View view = null;
if (name.equals(LAYOUT_FRAMELAYOUT))
{
view = new AutoFrameLayout(context, attrs);
}
if (name.equals(LAYOUT_LINEARLAYOUT))
{
view = new AutoLinearLayout(context, attrs);
}
if (name.equals(LAYOUT_RELATIVELAYOUT))
{
view = new AutoRelativeLayout(context, attrs);
}
if (view != null) return view;
return super.onCreateView(name, context, attrs);
}
}
在 AppCompatActivity 解析 xml 后,构造 View 时,会回调 onCreateView,这边可以实现一个拦截,构造自己的 View。拿 AutoFrameLayout 为例,继续看:
public class AutoFrameLayout extends FrameLayout
{
private final AutoLayoutHelper mHelper = new AutoLayoutHelper(this);
public AutoFrameLayout(Context context)
{
super(context);
}
public AutoFrameLayout(Context context, AttributeSet attrs)
{
super(context, attrs);
}
public AutoFrameLayout(Context context, AttributeSet attrs, int defStyleAttr)
{
super(context, attrs, defStyleAttr);
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public AutoFrameLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs)
{
return new LayoutParams(getContext(), attrs);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
if (!isInEditMode())
{
mHelper.adjustChildren();
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom)
{
super.onLayout(changed, left, top, right, bottom);
}
public static class LayoutParams extends FrameLayout.LayoutParams
implements AutoLayoutHelper.AutoLayoutParams
{
private AutoLayoutInfo mAutoLayoutInfo;
public LayoutParams(Context c, AttributeSet attrs)
{
super(c, attrs);
mAutoLayoutInfo = AutoLayoutHelper.getAutoLayoutInfo(c, attrs);
}
public LayoutParams(int width, int height)
{
super(width, height);
}
public LayoutParams(int width, int height, int gravity)
{
super(width, height, gravity);
}
public LayoutParams(ViewGroup.LayoutParams source)
{
super(source);
}
public LayoutParams(MarginLayoutParams source)
{
super(source);
}
public LayoutParams(FrameLayout.LayoutParams source)
{
super((MarginLayoutParams) source);
gravity = source.gravity;
}
public LayoutParams(LayoutParams source)
{
this((FrameLayout.LayoutParams) source);
mAutoLayoutInfo = source.mAutoLayoutInfo;
}
@Override
public AutoLayoutInfo getAutoLayoutInfo()
{
return mAutoLayoutInfo;
}
}
}
最后调用的核心代码:
public static int getPercentWidthSizeBigger(int val)
{
int screenWidth = AutoLayoutConifg.getInstance().getScreenWidth();
int designWidth = AutoLayoutConifg.getInstance().getDesignWidth();
int res = val * screenWidth;
if (res % designWidth == 0)
{
return res / designWidth;
} else
{
return res / designWidth + 1;
}
}
这边会根据屏幕宽高和设计图的宽高做一个比例换算,最终应用到控件的各种属性上,有点类似换肤的设计。但是这种方案目前在 issues 中反应出了一些问题:
首先一个问题就是不继承 view 的控件例如:alertDialog 或者 popupwindow 无法适配。还有一个问题是:这个适配方案没有考虑 statusbar 和 navagation bar,在带虚拟按键的手机上面会很明显 (目前已解决,提供了 API 设置)。
2. 今日屏幕适配方案
今日头条屏幕适配方案的核心原理在于,根据以下公式算出 density:
当前设备屏幕总宽度 (单位为像素) / 设计图总宽度 (单位为 dp) = density
density 的意思就是 1 dp 占当前设备多少像素。为什么要算出 density,这和屏幕适配有什么关系呢?
public static float applyDimension(int unit, float value,
DisplayMetrics metrics)
{
switch (unit) {
case COMPLEX_UNIT_PX:
return value;
case COMPLEX_UNIT_DIP:
return value * metrics.density;
case COMPLEX_UNIT_SP:
return value * metrics.scaledDensity;
case COMPLEX_UNIT_PT:
return value * metrics.xdpi * (1.0f/72);
case COMPLEX_UNIT_IN:
return value * metrics.xdpi;
case COMPLEX_UNIT_MM:
return value * metrics.xdpi * (1.0f/25.4f);
}
return 0;
}
大家都知道,不管你在布局文件中填写的是什么单位,最后都会被转化为 px,系统就是通过上面的方法,将你在项目中任何地方填写的单位都转换为 px 的。
所以我们常用的 px 转 dp 的公式 dp = px / density,就是根据上面的方法得来的,density 在公式的运算中扮演着至关重要的一步。要看懂下面的内容,还得明白,今日头条的适配方式,今日头条适配方案默认项目中只能以高或宽中的一个作为基准,进行适配,为什么不像 AndroidAutoLayout 一样,高以高为基准,宽以宽为基准,同时进行适配呢?
这就引出了一个现在比较棘手的问题,大部分市面上的 Android 设备的屏幕高宽比都不一致,特别是现在大量全面屏的问世,这个问题更加严重,不同厂商推出的全面屏手机的屏幕高宽比都可能不一致。这时我们只以高或宽其中的一个作为基准进行适配,就会有效的避免布局在高宽比不一致的屏幕上出现变形的问题,明白这个后,我再来说说 density,density 在每个设备上都是固定的,DPI / 160 = density,屏幕的总 px 宽度 / density = 屏幕的总 dp 宽度:
- 设备 1,屏幕宽度为 1080px,480DPI,屏幕总 dp 宽度为 1080 / (480 / 160) = 360 dp。
- 设备 2,屏幕宽度为 1440px,560DPI,屏幕总 dp 宽度为 1440 / (560 / 160) = 411 dp。
可以看到屏幕的总 dp宽度在不同的设备上是会变化的,但是我们在布局中填写的 dp 值却是固定不变的,这会导致什么呢?假设我们布局中有一个 View 的宽度为 100dp,在设备 1 中 该 View 的宽度占整个屏幕宽度的 27.8%(100 / 360 = 0.278),但在设备 2 中该 View 的宽度就只能占整个屏幕宽度的 24.3%(100 / 411 = 0.243),可以看到这个 View 在像素越高的屏幕上,dp 值虽然没变,但是与屏幕的实际比例却发生了较大的变化,所以肉眼的观看效果,会越来越小,这就导致了传统的填写 dp 的屏幕适配方式产生了较大的误差。
这时我们要想完美适配,那就必须保证这个 View 在任何分辨率的屏幕上,与屏幕的比例都是相同的。这时我们该怎么做呢?改变每个 View 的 dp 值?不现实,在每个设备上都要通过代码动态计算 View 的 dp 值,工作量太大。如果每个 View 的 dp 值是固定不变的,那我们只要保证每个设备的屏幕总 dp 宽度不变,就能保证每个 View 在所有分辨率的屏幕上与屏幕的比例都保持不变,从而完成等比例适配,并且这个屏幕总 dp 宽度如果还能保证和设计图的宽度一致的话,那我们在布局时就可以直接按照设计图上的尺寸填写 dp 值。
屏幕的总 px 宽度 / density = 屏幕的总 dp 宽度
在这个公式中我们要保证屏幕的总 dp 宽度和设计图总宽度一致,并且在所有分辨率的屏幕上都保持不变,我们需要怎么做呢?屏幕的总 px 宽度每个设备都不一致,这个值是肯定会变化的,这时今日头条的公式就派上用场了。
当前设备屏幕总宽度 (单位为像素) / 设计图总宽度 (单位为 dp) = density
这个公式就是把上面公式中的屏幕的总 dp 宽度换成设计图总宽度,原理都是一样的,只要 density 根据不同的设备进行实时计算并作出改变,就能保证设计图总宽度不变,也就完成了适配。
图片资源适配
mipmap:
采用 Android Studio 开发 Android APP,在项目的 res 目录下,会多出几个以 mipmap 开头的文件夹。
根据 Android 官方的描述,mipmap 仅仅用于存放 app 启动图标,可由 Image Asset Studio 生成。
drawable:
Android 系统可以在具有不同屏幕尺寸和密度的设备上运行,并将每个应用的用户界面调整为适应其显示的屏幕,会进行缩放和大小调整。为了最大程序优化更多设备上的用户体验,开发者需要针对不同的屏幕尺寸和密度优化应用。对于 Android 智能手机来说,屏幕大小、分辨率、密度均不尽相同,那么图片适配就成了 Android 中优化应用必不可少的环节之一。
- ldpi(低) ~120dpi
- mdpi(中) ~160dpi
- hdpi(高) ~240dpi
- xhdpi(超高) ~320dpi
- xxhdpi(超超高) ~480dpi
- xxxhdpi(超超超高) ~640dpi
Android 为了更好地优化应用在不同屏幕密度下的用户体验,在项目的 res 目录下可以创建 drawab-[density] (density 为6种通用密度名) 目录,开发者在进行 app 开发时,针对不同的屏幕密度,将图片放置于对应的 drawable-[density] 目录,Android 系统会依据特定的原则来查找各 drawable 目录下的图片。查找流程为:
- 1. 先查找和屏幕密度最匹配的文件夹。如当前设备屏幕密度 dpi 为 160,则会优先查找 drawable-mdpi 目录;如果设备屏幕密度 dpi 为 420,则会优先查找 drawable-xxhdpi 目录。
- 2. 如果在最匹配的目录没有找到对应图片,就会向更高密度的目录查找,直到没有更高密度的目录。例如,在最匹配的目录drawable-mdpi 中没有查找到,就会查找 drawable-hdpi 目录,如果还没有查找到,就会查找 drawable-xhdpi 目录,直到没有更高密度的 drawable-[density] 目录。
- 3. 如果一直往高密度目录均没有查找,Android 就会查找 drawable-nodpi 目录。drawable-nodpi 目录中的资源适用于所有密度的设备,不管当前屏幕的密度如何,系统都不会缩放此目录中的资源。因此,对于永远不希望系统缩放的资源,最简单的方法就是放在此目录中;同时,放在该目录中的资源最好不要再放到其他 drawable 目录下了,避免得到非预期的效果。
- 4. 如果在 drawable-nodpi 目录也没有查找到,系统就会向比最匹配目录密度低的目录依次查找,直到没有更低密度的目录。例如,最匹配目录是 xxhdpi,更高密度的目录和 nodpi 目录查找不到后,就会依次查找 drawable-xhdp、drawable-hdpi、drawable-mdpi、drawable-ldpi。
举个例子,假如当前设备的 dpi 是 320,系统会优先去 drawable-xhdpi 目录查找,如果找不到,会依次查找 xxhdpi → xxxhdpi → nodpi hdpi → hdpi → mdpi → ldpi。对于不存在的 drawable-[density] 目录直接跳过,中间任一目录查找到资源,则停止本次查找。
图片的缩放:
前述说到 Android 为了能够更好地适配各种屏幕,会依据当前设备的 dpi 对 drawable-[density] 目录中的图片进行缩放,那么什么情况下图片被放大,什么情况下图片被缩小呢?
为了更好的描述,把"符合当前设备 dpi 的 drawable 目录"表示为"匹配目录"。比如,设备的 dpi 为 320,这匹配目录为drawable-xhdpi;设备的 dpi 为 150,则匹配目录为 drawable-mdpi。图片的放大和缩小遵循以下规律:
- 如果图片所在目录为匹配目录,则图片会根据设备 dpi 做适当的缩放调整。
- 如果图片所在目录 dpi 低于匹配目录,那么该图片被认为是为低密度设备需要的,现在要显示在高密度设备上,图片会被放大。
- 如果图片所在目录 dpi 高于匹配目录,那么该图片被认为是为高密度设备需要的,现在要显示在低密度设备上,图片会被缩小。
- 如果图片所在目录为 drawable-nodpi,则无论设备 dpi 为多少,保留原图片大小,不进行缩放。
那么六种通用密度下的缩放倍数是多少呢?以 mdpi 为基线,各密度目录下的放大倍数 (即缩放因子 density) 如下:
密度 | 放大倍数 |
---|---|
ldpi | 0.75 |
mdpi | 1.0 |
hdpi | 1.5 |
xhdpi | 2.0 |
xxhdpi | 3.0 |
xxxhdpi | 4.0 |
公式为: