目前做APP的时候,尤其应用跑在平板上的时候,发现了一个比较难解决的安卓碎片化的问题:同样分辨率的平板,屏幕密度是不相同的,甚至于不同品牌的手机也是不相同的。
下面随便举了一些例子:
型号 | 类型 | 屏幕宽度 | 屏幕高度 | 屏幕密度 |
rockchip | 平板 | 1920 | 1128 | 1.5 |
华为C5 | 平板 | 1920 | 1200 | 2 |
华为C3 | 平板 | 1280 | 800 | 1.275 |
小米10 | 手机 | 1080 | 2120 | 2.75 |
华为nova4 | 手机 | 1080 | 2108 | 3 |
vivo | 手机 |
屏幕上显示的单位都是以px为准的,而我们代码中一般设置的单位是dp。
换算关系如下:px=dp*density。
比如说我代码中设置一个button的高度为20dp,在华为C5平板上的高度就是40px,在rockchip平板上显示的高度就是30px。
但是这两个平板的分辨率几乎是一样的,这样就会产生一个问题,根据UI稿,如果我们按照C5平板的density来设计,那么在rockchip平板上就会显示的很大,会比设计稿大30%。
如下图所示,针对不同的分辨率创建不同的values文件夹,并且添加不同的dimens文件。而dimens针对不同的dp配置不同的px值
使用的时候我们直接使用dipxx代替原来的dp值,如下。
1280x800的分辨率下,我配置的dip16=32px
1920x1080的分辨率下,我配置的dip16=48px
则上面的TextView,在1280x800展示大小为32px*32px,在1920x1080展示的大小为48px*48px
代码中所有使用dp的地方,全部改为dipxx替代。这样等于针对不同的分辨率直接使用px,从而解决了density不一致的问题。
附送values文件生成代码:
public class DimenCreate {
private String dirStr = "./res";
private final static String TemplateDP = "{1} \n";
private final static String Template_DP = "{1} \n";
private final static String TemplateSP = "{1} \n";
/**
* {0}-HEIGHT
*/
private final static String VALUE_TEMPLATE = "values-{0}x{1}";
public static void main(String[] args) {
HashMap params = new HashMap<>();
params.put("0,0", 1.0f);
params.put("1080,1920", 3.0f);
params.put("720,1280", 2.0f);
params.put("800,1223", 2.0f);
params.put("708,1366", 2.0f);
params.put("1200,1920", 3.0f);
params.put("1080,2107", 3.0f);
params.put("360,640", 1.0f);
new DimenCreate().generate(params);
}
public void generate(HashMap params) {
for (String key : params.keySet()) {
String[] wh = key.split(",");//宽高
Float density = params.get(key);
generateXmlFile(Integer.parseInt(wh[0]), Integer.parseInt(wh[1]), density);
}
}
private void generateXmlFile(int w, int h, float density) {
StringBuffer sbForWidth = new StringBuffer();
sbForWidth.append("\n");
sbForWidth.append("\n");
int bigger = w > h ? w : h;
if (bigger == 0) {
bigger = 640;
}
float baseFloat = bigger / density;
System.out.println("width : " + w + ",height : " + h + ",bigger:" + bigger + ",baseFloat:" + baseFloat);
//spxx
for (int i = 1; i <= 50; i++) {
if (w == 0) {
sbForWidth.append(TemplateSP.replace("{0}", i + "").replace("{1}",
change(density * i, w, "sp") + ""));
} else {
sbForWidth.append(TemplateSP.replace("{0}", i + "").replace("{1}",
change(density * i, w, "sp") + ""));
}
}
//dip_xx
for (int i = 1; i <= 50; i++) {
sbForWidth.append(Template_DP.replace("{0}", i + "").replace("{1}",
change(density * i * -1, w, "dp")));
}
//dipxx
for (int i = 1; i <= baseFloat; i++) {
sbForWidth.append(TemplateDP.replace("{0}", i + "").replace("{1}",
change(density * i, w, "dp") + ""));
}
sbForWidth.append(" ");
File fileDir = new File(dirStr + File.separator
+ VALUE_TEMPLATE.replace("{0}", h + "")//
.replace("{1}", w + ""));
fileDir.mkdir();
File layxFile = new File(fileDir.getAbsolutePath(), "common_dimens.xml");
try {
PrintWriter pw = new PrintWriter(new FileOutputStream(layxFile));
pw.print(sbForWidth.toString());
pw.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
}
}
//values中的也要考虑到
//保留一位小数
public static String change(float a, float w, String unit) {
if (w == 0) {
return a + unit;
}
int temp = (int) (a * 100);
return String.format("%.1f", temp / 100f) + "px";
}
}
优点:对原生无侵入,原理简单。
缺点:
1.对原有的代码代码量较大,所以使用到dp的地方都需要替换。(可以写脚本进行这种替换)
2.横竖屏切换的时候会导致不适配。横竖屏切换的时候,上述配置文件是失效的。
因为横竖屏切换的时候会导致配置文件失效,这时候展示出来的界面控件大小是有问题的,所以这种方案使用了一段之后,最终被放弃。
但是如果没有横竖屏切换的需求,全部都是mainfest里面写死的横屏或者竖屏,是没有的问题。
上面说的是针对分辨率设置不同的配置文件,在横竖屏切换的时候有问题,那么能否按照竖屏的方式再去配置一套呢?搜了一圈,果然可以。
如下图所示,同样1920*1080分辨率情况下,密度不同,则dp值不同。如果我设置两个dp值适配的文件进行适配,则可以解决这个问题。
其中编号3中的dimens文件,大小可以设置为编号1中的1.5倍。
编号 | 分辨率 | 密度 | dp值 | 缩放比 |
1 | 1920*1080 | 3 | 360 | 1 |
2 | 1280*720 | 2 | 360 | 1 |
3 | 1920*1080 | 2 | 540 | 1.5 |
4 | 1280*720 | 1.5 | 480 | 1.33 |
w360dp设置:
w540dp中设置:
这里要额外说一下,这套适配和上面方案一的适配是可以同时使用的。方案二的优先级要高于方案一,即如果手机或平板同时命中方案一的分辨率和方案二的dp值时,优先按照方案二进行适配。
方案二其实已经满足我的需求了,因为它已经解决了同样分辨率不同密度展示不相同的问题。但是由于我之前的方案使用的是方案一,所以使用方案二的话会导致方案一完全失效,会影响所有的控件。风险性较大。
另外方案二还有一个缺陷,就是横竖屏切换的时候,适配的是不同的文件夹。比如我1920*1200,屏幕密度是2。那么横屏显示的时候适配的是w960dp的文件夹,竖屏显示的时候适配的是w600dp的文件夹。这时候如果我要设置宽度占横屏时屏幕的一半,则应该dip320,实际展示出来,宽度为2*1.5*320=960px,正好为屏幕的一半宽度。
这时候如果有一个屏幕分辨率是1800*1080的,屏幕密度是3的。则也适用w600dp的文件夹。这时候dip320的时候,宽度为3*1.5*320=1440,那这时候就有问题了。
这个是参照今日头条的一个方案,所有安卓设备最终展示在屏幕上面的都是像素px,既然是像素,那么我们代码中写的dp,sp什么的,就一定有一个地方转换为像素值,这个地方就是TypedValue类的applyDimension方法:
/**
* Converts an unpacked complex data value holding a dimension to its final floating
* point value. The two parameters unit and value
* are as in {@link #TYPE_DIMENSION}.
*
* @param unit The unit to convert from.
* @param value The value to apply the unit to.
* @param metrics Current display metrics to use in the conversion --
* supplies display density and scaling information.
*
* @return The complex floating point value multiplied by the appropriate
* metrics depending on its unit.
*/
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;
}
这里我们就举一个例子就好了,TextView设置字号:
如下图所示,setTextSize默认传入的是sp,调用setTextSizeInternal方法,通过TypedValue.applyDimension方法把sp转换为最终的像素值,传入setRawTextSize方法。setRawTextSize中把size设置给TextPaint,所以我们也知道了,TextPaint接收的单位是像素值。
public void setTextSize(float size) {
setTextSize(TypedValue.COMPLEX_UNIT_SP, size);
}
public void setTextSize(int unit, float size) {
if (!isAutoSizeEnabled()) {
setTextSizeInternal(unit, size, true /* shouldRequestLayout */);
}
}
private void setTextSizeInternal(int unit, float size, boolean shouldRequestLayout) {
Context c = getContext();
Resources r;
if (c == null) {
r = Resources.getSystem();
} else {
r = c.getResources();
}
setRawTextSize(TypedValue.applyDimension(unit, size, r.getDisplayMetrics()),
shouldRequestLayout);
}
private void setRawTextSize(float size, boolean shouldRequestLayout) {
if (size != mTextPaint.getTextSize()) {
mTextPaint.setTextSize(size);
if (shouldRequestLayout && mLayout != null) {
// Do not auto-size right after setting the text size.
mNeedsAutoSizeText = false;
nullLayouts();
requestLayout();
invalidate();
}
}
}
OK,回归正题,如何修改像素值呢?这个很简单,都不需要反射,直接通过resources获取DisplayMetrics对象,然后直接粗暴的改掉就好了,简单的有点不可思议。由于进程是共享一个resources的,所以直接在application中进行一次设置,整个APP都生效了。我们公司的设计一般是按照360*640进行设置的,所以我只要按照这个比例去设置密度值即可:
比如:
1920*1080/1920*1200等,密度值是3
1280*720/1280*800等,密度值是2
代码如下,在闪屏页进行加载。
public static void setCustomDensity(Activity activity, Application application) {
//application
final DisplayMetrics appDisplayMetrics = application.getResources().getDisplayMetrics();
int max = Math.max(appDisplayMetrics.widthPixels, appDisplayMetrics.heightPixels);
//计算宽为360dp 同理可以设置高为640dp的根据实际情况
final float targetDensity = Math.round((double) max / 640);
final int targetDensityDpi = (int) (targetDensity * 160);
appDisplayMetrics.density = appDisplayMetrics.scaledDensity = targetDensity;
appDisplayMetrics.densityDpi = targetDensityDpi;
//activity
final DisplayMetrics activityDisplayMetrics = activity.getResources().getDisplayMetrics();
activityDisplayMetrics.density = appDisplayMetrics.scaledDensity = targetDensity;
activityDisplayMetrics.densityDpi = targetDensityDpi;
}
然后我就惊喜的发现,所有界面都已经生效了。
这个方案虽然好,但是使用的过程中也发现了一些问题。于是整理了一下原因和解决方案,汇总如下:
问题点1:有的时候发现密度值会失效,恢复到未修改的原始的状态
这个最终调研下来,发现自定义加载布局的时候就会出现这种问题,getWindow().getLayoutInflater().inflate(id, parent, false)。
具体原因没有根究,就简单的改为在每个onCreate方法调用setCustomDensity方法。