Android 发展到现在,已经到了Android Q。前几天的新闻,Google结束以“甜点”命名系统的方式,Android Q 改为了 Android 10。由于Android的开源,就会有诸多厂商,每家厂商会生产不同屏幕大小,不同分辨率的手机。身为一个Android开发者,就必须了解Android的屏幕适配。
1. px 和 dp
分辨率(px)
分辨率就是手机屏幕的像素点。一般为屏幕的 “宽 x 高”。下图是小米9的官方参数,分辨率为 2340 x 1080,表示在屏幕宽度方向有1080个像素点,在高度方向有2340个像素点。
屏幕尺寸(英寸 inch)
按照屏幕对角测量的实际物理尺寸。如下图所示:
屏幕密度(DPI)
每英寸的像素点数,数值越高当然显示越清晰。
例如上面的小米9的屏幕密度为403。
密度无关像素 (dp)
dp 是一个虚拟像素单位,1 dp 约等于中密度屏幕(160dpi;“基准”密度)上的 1 像素。dp 单位转换为屏幕像素: px = dp * (dpi / 160)。 例如,在 240 dpi 屏幕上,1 dp 等于 1.5 物理像素。在定义应用的 UI 时应始终使用 dp 单位 ,以确保在不同密度的屏幕上正常显示 UI。
2. 适配方案
Android屏幕适配存在多种适配方案:屏幕分辨率限定符适配和 smallestWidth 适配以及今日头条适配方案。下面分别对着几种方案进行逐一的进行描述。
3. 屏幕分辨率限定符适配
根据当前市面上手机的屏幕的分辨率创建不同的文件夹,系统运行的时候,会自动去选择读取对应的文件夹中的xml,即每种屏幕分辨率的设备需要定义一套 dimens.xml 文件,这里偷懒只写了几种,并以 320x480 为基准:
工程目录:
布局文件:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="@dimen/x100"
android:layout_height="@dimen/x100"
android:background="@color/colorAccent"
android:gravity="center"
android:text="@string/textbtn"
android:textColor="#ffffff"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0" />
</androidx.constraintlayout.widget.ConstraintLayout>
在values-320x480文件夹中的 dimens.xml 中定义:
在values-1080x1920文件夹中的 dimens.xml 中定义:
在不同values的文件夹中定义值不同的文字内容,最终的显示结果如下:
从此图中可以看出,在屏幕大小一样,分辨率不同的情况下,控件的大小差不多。
如果都是控件的大小都固定是 300px 的时候,屏幕显示如下:
如果都是控件的大小都固定是 300dp 的时候,屏幕显示如下:
从以上的对比,可以看出,有做适配和没做适配,控件的大小相差很大的。但是这种适配方案是存在缺点的。
缺点:容错机制很差,需要的定义对应的分辨率才能适配,否则就会去读取默认 values 文件中的大小。使用默认的尺寸的话,UI就很可能变形。
4. smallestWidth 适配方案
smallestWidth 限定符适配原理跟上一种方案的原理一样,指的是Android系统会识别屏幕可用高度和宽度的最小尺寸的dp值(其实就是手机的宽度值),然后根据识别到的结果去资源文件中寻找对应限定符的文件夹下的资源文件。
工程目录:
布局文件:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="@dimen/qb_px_100"
android:layout_height="@dimen/qb_px_100"
android:background="@color/colorAccent"
android:gravity="center"
android:text="@string/textbtn"
android:textColor="#ffffff"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0" />
</androidx.constraintlayout.widget.ConstraintLayout>
显示如下:
smallestWidth限定符适配和宽高限定符适配最大的区别在于,smallestWidth限定符适配有很好的容错机制,例如:如果没有value-sw360dp文件夹,系统会向下寻找,比如离360dp最近的只有value-sw350dp,那么Android就会选择value-sw350dp文件夹下面的资源文件。这个特性就解决了上一种方案的容错问题。
5. 今日头条适配方案
Android中在渲染屏幕时,都会将在xml中的dp单位转化为px,去渲染到设备中,用到的转换单位如下:
px =dp * density;
density=dpi/160;
px=dp*(dpi/160);
在上面的两种适配方案中,都是根据不同的屏幕大小设置不同的控件的宽高值。在公式 px =dp * density 中,不同屏幕的dp,以及density都是不同,从而实现不同的手机渲染有着不同的px。在今日头条的适配方案的思路是假定每个手机的屏幕宽度是固定的。比如设计稿宽度是360dp, 想要保证在所有设备计算得出的px值都正好是屏幕宽度的话,只能修改 density 的值。
在xml中设置宽高后,都是通过以下代码进行转换:
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;
}
当在xml中设置的单位为px, 则直接用对应px的值;当单位为dp,则返回的是:value * metrics.density;当单位为sp,则返回的是value * metrics.scaledDensity。今日头条
选用dp
作为适配单位,给出的理由是项目中大部分都是使用dp
做单位。
如果UI小姐姐给的设计图是 360dp(宽) x 640dp(高) 的设计图,如果要适配所有的屏幕,则 density = 设备屏幕的真实宽度(单位:px) / 360。这里为什么会是这样呢?上面公式中:px =dp * density,假定了所有的屏幕的宽度都是360dp,而px的值就是设备屏幕的真实宽度,所以就有了:density = 设备屏幕的真实宽度(单位:px) / 360。这样1dp在所有屏幕的宽中所占的比率都是一样的,都是1/360,在xml中就可以按照设计图来定义了。
而对于字体,Google推荐设置的单位为sp,在上面的源码中,字体的转换公式为:px= value * metrics.scaledDensity,这里的value 是在xml中定义的以sp为单位的值。一般情况下,scaledDensity 的值与 density 是相等的。但是如果在系统中设置了改变了字体的大小,scaledDensity 的值与 density 就不相等了。scaledDensity = 人为修改的density * (系统的ScaledDensity / 系统的Density)。如果不需要字体大小随系统设置而改变,就直接使用dp
做单位好了。
以下代码是今日头条给出的案例代码:
// 系统的Density
private static float sNoncompatDensity;
// 系统的ScaledDensity
private static float sNoncompatScaledDensity;
public static void setCustomDensity(Activity activity, Application application) {
DisplayMetrics displayMetrics = application.getResources().getDisplayMetrics();
if (sNoncompatDensity == 0) {
sNoncompatDensity = displayMetrics.density;
sNoncompatScaledDensity = displayMetrics.scaledDensity;
// 监听在系统设置中切换字体
application.registerComponentCallbacks(new ComponentCallbacks() {
@Override
public void onConfigurationChanged(Configuration newConfig) {
if (newConfig != null && newConfig.fontScale > 0) {
sNoncompatScaledDensity=application.getResources().getDisplayMetrics().scaledDensity;
}
}
@Override
public void onLowMemory() {
}
});
}
// 此处以360dp的设计图作为例子
float targetDensity=displayMetrics.widthPixels/360;
float targetScaledDensity=targetDensity*(sNoncompatScaledDensity/sNoncompatDensity);
int targetDensityDpi= (int) (160 * targetDensity);
displayMetrics.density = targetDensity;
displayMetrics.scaledDensity = targetScaledDensity;
displayMetrics.densityDpi = targetDensityDpi;
DisplayMetrics activityDisplayMetrics = activity.getResources().getDisplayMetrics();
activityDisplayMetrics.density = targetDensity;
activityDisplayMetrics.scaledDensity = targetScaledDensity;
activityDisplayMetrics.densityDpi = targetDensityDpi;
}
实例如下:
xml文件,在这里为了看得明显,将宽高丢设置为200dp:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/mTv"
android:layout_width="200dp"
android:layout_height="200dp"
android:background="@color/colorAccent"
android:gravity="center"
android:text="鹭岛猥琐男"
android:textColor="#ffffff"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0" />
</androidx.constraintlayout.widget.ConstraintLayout>
没有加上适配方案的效果如下图:
加上今日头条的适配方案这里也是以360dp为基准,这里直接放在Activity中。在实际项目中放在 BaseActivity 中会省略每个Activity都有重复代码的问题。代码如下:
package cn.zzw.messenger.jinritoutiaodemo;
import androidx.appcompat.app.AppCompatActivity;
import android.app.Activity;
import android.app.Application;
import android.content.ComponentCallbacks;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.os.Bundle;
import android.util.DisplayMetrics;
import android.view.View;
import android.widget.TextView;
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setCustomDensity(this,getApplication());
setContentView(R.layout.activity_main);
TextView mTv = findViewById(R.id.mTv);
}
// 系统的Density
private static float sNoncompatDensity;
// 系统的ScaledDensity
private static float sNoncompatScaledDensity;
public static void setCustomDensity(Activity activity, final Application application) {
DisplayMetrics displayMetrics = application.getResources().getDisplayMetrics();
if (sNoncompatDensity == 0) {
sNoncompatDensity = displayMetrics.density;
sNoncompatScaledDensity = displayMetrics.scaledDensity;
// 监听在系统设置中切换字体
application.registerComponentCallbacks(new ComponentCallbacks() {
@Override
public void onConfigurationChanged(Configuration newConfig) {
if (newConfig != null && newConfig.fontScale > 0) {
sNoncompatScaledDensity = application.getResources().getDisplayMetrics().scaledDensity;
}
}
@Override
public void onLowMemory() {
}
});
}
// 此处以360dp的设计图作为例子
float targetDensity = displayMetrics.widthPixels / 360;
float targetScaledDensity = targetDensity * (sNoncompatScaledDensity / sNoncompatDensity);
int targetDensityDpi = (int) (160 * targetDensity);
displayMetrics.density = targetDensity;
displayMetrics.scaledDensity = targetScaledDensity;
displayMetrics.densityDpi = targetDensityDpi;
DisplayMetrics activityDisplayMetrics = activity.getResources().getDisplayMetrics();
activityDisplayMetrics.density = targetDensity;
activityDisplayMetrics.scaledDensity = targetScaledDensity;
activityDisplayMetrics.densityDpi = targetDensityDpi;
}
}
运行后的效果图如下:
从这两次的效果图,可以很明显的看出加了适配方案后,两个屏幕中控件的大小差不多一致了。此套方案对于原来的老项目不太友好,因为改了屏幕的density,所有布局的实际尺寸都会发生变化,整个布局中的所有尺寸都要进行修改一遍。至于稳定性,字节跳动这种大公司都用在今日头条这种项目上,还是不需要有太多的担心的。
参考:
https://developer.android.google.cn/guide/practices/screens_support?hl=en
https://www.jianshu.com/p/a4b8e4c5d9b0
https://mp.weixin.qq.com/s/X-aL2vb4uEhqnLzU5wjc4Q
https://mp.weixin.qq.com/s/d9QCoBP6kV9VSWvVldVVwA