Pacer首页适配方案

印曜灿
2023-12-01

需求-为什么要适配

app首页是一个不可以滑动的页面,因此需要高和宽同时适配;内容比较多——日期,抽奖按钮,步数表盘,步数柱状图,底部的Tab,广告等;首页中心是一个圆形表盘,高度取剩余高度,宽度取屏幕宽度,并且考虑高宽相等,两方面适应之后决定圆形表盘的直径,导致在不同手机上效果千奇百怪。

适配方案的选择

常见的适配方案,可以参考这篇文章——《Android 目前稳定高效的UI适配方案》,文中提到的几种适配方案包括:

  1. dp 直接适配
  2. 宽高限定符适配
  3. SamllestWidth适配
  4. 今日头条适配方案

对于Pacer这个App和首页的适配需求来说:

  1. 使用dp直接适配方案,dp作为像素独立单位,配合使用wrap_content, match_parent, weight等参数,可以满足大部分业务需求。但是主页需要精确到比例的适配,不适合。
  2. 宽高限定符适配方案,需要穷举市面上所有的Android手机的宽高像素值。屏幕碎片化越来越严重,不适合;而且长宽同时适配,比例失调。
  3. SamllestWidth适配方案,也叫sw限定符适配。指的是Android会识别屏幕可用高度和宽度的最小尺寸的dp值(其实就是手机的宽度值),然后根据识别到的结果去资源文件中寻找对应限定符的文件夹下的资源文件。维护麻烦,而且首页不是比例的问题,不是只有宽度适配,需要高度宽度同时适配,只在一个维度上的适配不能满足。
  4. 今日头条适配方案本质也是按照比例缩放,可侵入性低,而且以此为基础的AutoSize框架又增加了一些好用的接口方法,满足一般页面的适配和首页的宽度适配,在高度上自行设计同时满足高度宽度适配。

具体适配方案

1.使用AndroidAutoSize屏幕适配框架

使用dp为单位,让屏 幕宽度不等于设计图的都能很好的适配,以宽为基准进行适配。

可以解决大部分页面效果不统一的情况。

2.首页需要宽度高度同时适配

设定宽度基准之后,假设宽度都是360的基础上,计算首页中心fragment的高度:

fragment高度=屏幕高度 - 系统导航栏高度 - 系统状态栏高度 - app广告高度 - app导航tab高度 - 头部按钮高度

根据不同的fragment高度,划分到5档,5个区间高度分别为:

  1. 大于520dp
  2. 520 到 456
  3. 455 到 379
  4. 378 到 342
  5. 小于342

由UI分别按照比例设计5套UI,尽可能保证在不同宽高比的屏幕上,视觉效果统一。

代码上,根据fragment高度,加载5套布局文件中的一套。

3.ConstraintLayout和Space保证首页布局效果

布局文件的区别体现在:

1.字体大小不同

2.控件大小不同

因为布局的高度在一定范围内而不是确定的值,所以需要控件之间的间隔在一定范围内可以自适应。

首页内容比较多,使用ConstaintLayout布局,减少嵌套层次。

使用Space控件来做组件之间的间隔,Space控件的高度使用权重(layout_constraintVertical_weight)设置,保证5个档位内的高度范围内,按照比例分配间隔高度。

Space 经常用于组件之间的缝隙,其draw()为空,减少了绘制渲染的过程。

组件之间的距离使用 Space 会提高了绘制效率,特别是对于动态设置间距会很方便高效。

正是因为draw()为空,对该 view 没有做任务绘制渲染,所以不能对 Space 设置背景色。

4.缺点

因为首页+左滑+右滑,共3个fragment,都是圆盘结构,都需要宽度高度同时适配,所以一共需要3*5=15个layout文件。

5.升级版方案

首页改版为可滑动,通过布局自适应不能满足自动填充一整屏幕的需求,改为通过代码计算高度进行设置。

具体实施——工具类ScreenAutoSizeUtil

将比例写入map里面,将其他元素高度设定之后,用代码通过比例和剩余总高度计算真实高度,设置到代码中。节省了布局文件,可读性变差,灵活性高。(待补充)

AndriodAutoSize原理

原理同今日头条适配方案原理相同。

原因

设计稿宽度是固定的,只有一种宽度,而屏幕的真是宽度其实是有很多的。

虽然dp的出现目的在于取代像素,尽量保证屏幕的宽度统一,但是屏幕碎片化严重,导致以dp为单位,宽度还是有很多种,不能统一。

原理

在屏幕绘制的时候,需要把单位换算成px,也就是最终是使用px为单位进行绘制的。平时我们使用dp为单位写布局之后,最终要使用公式px=dp * density转换为px进行绘制。

这套方案,相当于修改了dp的定义——dp代表的是屏幕宽度平均分成【设计图宽度】的份数之后的1单位长度。

比如屏幕宽度是1080px,用dp为单位时屏幕宽度是480dp,density=2.25。

要使用这个方案的话,假设我们的设计图宽度是360,我们修改了dp的定义,dp的新定义是讲屏幕平分为360份之后的单位长度(也就是3px),按照这个dp的数值计算出density应该修改为3。

我们再使用新的dp去绘制控件的宽度,最终在绘制的时候再用新的density去计算成px:

​ 屏幕宽度 x density=360 x 3 = 1080

在像素这个单位上,宽度没有问题,还是真实宽度。

简单说,方案通过在 Acivity#onCreate 中修改 density 的值,强行把所有不同尺寸分辨率的手机的屏幕宽度dp值改成一个统一的值。

可行性

主要回答3个问题:

  1. 为什么density可以修改?
  2. 为什么不会影响其他APP
  3. 如何做到可以指定Activity不适配、单独适配的?

1. 为什么density可以修改?

先说结论:density不同于dpi,dpi是有物理含义的,表示每英寸的像素数,屏幕的物理宽度确定,像素数量确定,dpi就是确定的;density不是一个固定在系统中的值,而是一个“用于显示的逻辑密度”(The logical density of the display)在程序启动之后通过真实物理中的dpi和规则(160为标准)计算出来的,这个规则是以160为标准,是一个类似约定俗成的参考标准。density是public成员,因此可以很方便的修改。

阅读源码,查看density的值是怎么来的:
density 是 DisplayMetrics 中的成员变量,DisplayMetrics 实例通过 Resources#getDisplayMetrics 可以获得:

public DisplayMetrics getDisplayMetrics() {
        return mResourcesImpl.getDisplayMetrics();
    }

mResourcesImpl是ResourcesImpl实例,查看ResourcesImpl#getDisplayMetrics :

DisplayMetrics getDisplayMetrics() {
        if (DEBUG_CONFIG) Slog.v(TAG, "Returning DisplayMetrics: " + mMetrics.widthPixels
                + "x" + mMetrics.heightPixels + " " + mMetrics.density);
        return mMetrics;
    }

返回的mMetrics是ResourcesImpl的对象,来看它是怎么创建和赋值的。

private final DisplayMetrics mMetrics = new DisplayMetrics();
……
public ResourcesImpl(@NonNull AssetManager assets, @Nullable DisplayMetrics metrics,
            @Nullable Configuration config, @NonNull DisplayAdjustments displayAdjustments) {
        mAssets = assets;
        mMetrics.setToDefaults();
        mDisplayAdjustments = displayAdjustments;
        mConfiguration.setToDefaults();
        updateConfiguration(config, metrics, displayAdjustments.getCompatibilityInfo());
    }

查看DisplayMetrics中density的描述和DisplayMetrics#setToDefaults方法,DisplayMetrics的density是一个“逻辑”密度,无关像素的比例因子。它是通过设备dpi除以160(DENSITY_DEFAULT)计算出来的,就是设备dpi是真实的,而它是考虑比例之后规定出来的。

public class DisplayMetrics {		
  ……略……
     /**
     * The logical density of the display.  This is a scaling factor for the
     * Density Independent Pixel unit, where one DIP is one pixel on an
     * approximately 160 dpi screen (for example a 240x320, 1.5"x2" screen), 
     * providing the baseline of the system's display. Thus on a 160dpi screen 
     * this density value will be 1; on a 120 dpi screen it would be .75; etc.
     *  
     * <p>This value does not exactly follow the real screen size (as given by 
     * {@link #xdpi} and {@link #ydpi}, but rather is used to scale the size of
     * the overall UI in steps based on gross changes in the display dpi.  For 
     * example, a 240x320 screen will have a density of 1 even if its width is 
     * 1.8", 1.3", etc. However, if the screen resolution is increased to 
     * 320x480 but the screen size remained 1.5"x2" then the density would be 
     * increased (probably to 1.5).
     *
     * @see #DENSITY_DEFAULT
     */
    public float density;
  
……略……
  
public void setToDefaults() {
        widthPixels = 0;
        heightPixels = 0;
        density =  DENSITY_DEVICE / (float) DENSITY_DEFAULT;
        densityDpi =  DENSITY_DEVICE;
        scaledDensity = density;
        xdpi = DENSITY_DEVICE;
        ydpi = DENSITY_DEVICE;
        noncompatWidthPixels = widthPixels;
        noncompatHeightPixels = heightPixels;
        noncompatDensity = density;
        noncompatDensityDpi = densityDpi;
        noncompatScaledDensity = scaledDensity;
        noncompatXdpi = xdpi;
        noncompatYdpi = ydpi;
    }
  ……略……

上述代码中的DENSITY_DEFAULT的数值就是160。

以上就是density的计算过程,所以density是一个计算出来的值,用途是在使用dp为单位,计算转换成px时使用的比例因子,影响dp到px的转换。对其进行修改,需要保证修改后dp转换成px不会出错,也就是响应的使用的dp的值要配套。

2. 为什么不会影响其他APP

阅读源码,可以发现:
density 是 DisplayMetrics 中的成员变量,DisplayMetrics 是 Resource 的成员变量,Resource 是 Activity 的父类 ContextWrapper 的成员变量,因此 density 对于各个 Activity 来说是自己持有的,Activity 修改 density 只影响当前 Activity 。
修改的地方选在Activity#onCreate方法里。
View绘制的时候会获取DisplayMetrics,用到的也是当前Activity的resource。

3. 如何做到可以指定Activity不适配、单独适配的?

同2的原因一样,因为density是以Activity为单位的,因此只要在该Activity的onCreate方法中选择是否修改density,如何修改density就可以对Activity进行是否适配,单独适配了。
所以框架才能配置哪些页面进行适配,哪些不进行适配,哪些个性化适配。

AndroidAutoSize源码分析

代码结构

├── external
│   ├── ExternalAdaptInfo.java
│   ├── ExternalAdaptManager.java
│── internal
│   ├── CancelAdapt.java
│   ├── CustomAdapt.java
│── unit
│   ├── Subunits.java
│   ├── UnitsManager.java
│── utils
│   ├── AutoSizeUtils.java
│   ├── LogUtils.java
│   ├── Preconditions.java
│   ├── ScreenUtils.java
├── ActivityLifecycleCallbacksImpl.java
├── AutoAdaptStrategy.java
├── AutoSize.java
├── AutoSizeConfig.java
├── DefaultAutoAdaptStrategy.java
├── DisplayMetricsInfo.java
├── FragmentLifecycleCallbacksImpl.java
├── InitProvider.java

自动运行——启动和初始化

使用者只需要在 AndroidManifest.xml 中填写一下 meta-data 标签,其他什么都不做,AndroidAutoSize 就能自动运行,并在 App 启动时自动解析。

原理:ContentProvider的onCreate的调用时机介于Application的attachBaseContext和onCreate之间,Provider的onCreate优先于Application的onCreate执行,并且此时的Application已经创建成功,这样就不需要调用方在Application里去进行初始化,框架可以自动运行了。

InitProvider#onCreate中,初始化框架:

public boolean onCreate() {
    AutoSizeConfig.getInstance()
            .setLog(true)
            .init((Application) getContext().getApplicationContext())
            .setUseDeviceSize(false);
    return true;
}

AutoSizeConfig

参数配置类, 给 AndroidAutoSize 配置一些必要的自定义参数

DefaultAutoAdaptStrategy中做了什么?

如何适配

自定义一个DefaultAutoAdaptStrategy类型,实现AutoAdaptStrategy接口的applyAdapt方法——开始执行屏幕适配逻辑,applyAdapt方法中判断是否对第三方适配,是否是不适配,是否单独适配等 。

最后调用的是适配:

//如果 target 实现 CustomAdapt 接口表示该 target 想自定义一些用于适配的参数, 从而改变最终的适配效果
if (target instanceof CustomAdapt) {
  LogUtils.d(String.format(Locale.ENGLISH, "%s implemented by %s!", target.getClass().getName(), CustomAdapt.class.getName()));
  AutoSize.autoConvertDensityOfCustomAdapt(activity, (CustomAdapt) target);
} else {
  LogUtils.d(String.format(Locale.ENGLISH, "%s used the global configuration.", target.getClass().getName()));
  AutoSize.autoConvertDensityOfGlobal(activity);
}

ActivityLifecycleCallbacksImpl 实现 Application.ActivityLifecycleCallbacks接口,可用来代替在 BaseActivity 中加入适配代码的传统方式,这种方案类似于 AOP, 面向接口, 侵入性低, 方便统一管理, 扩展性强, 并且也支持适配三方库的Activity。

自定义ActivityLifecycleCallbacksImpl实现Application.ActivityLifecycleCallbacks接口,用于registerActivityLifecycleCallbacks中,在ActivityLifecycleCallbacksImpl写onActivityCreated中调用applyAdapt,进行适配。

具体适配方法位于AutoSize类。

还提供了一些接口:onAdaptListener(onAdaptBefore,onAdaptAfter)

让某个页面取消适配

AutoSize#cancelAdapt中,做到如何取消适配——调用setDensity方法将density设置成原来的density

   /**
     * 取消适配
     *
     * @param activity {@link Activity}
     */
    public static void cancelAdapt(Activity activity) {
        float initXdpi = AutoSizeConfig.getInstance().getInitXdpi();
        switch (AutoSizeConfig.getInstance().getUnitsManager().getSupportSubunits()) {
            case PT:
                initXdpi = initXdpi / 72f;
                break;
            case MM:
                initXdpi = initXdpi / 25.4f;
                break;
            default:
        }
        setDensity(activity, AutoSizeConfig.getInstance().getInitDensity()
                , AutoSizeConfig.getInstance().getInitDensityDpi()
                , AutoSizeConfig.getInstance().getInitScaledDensity()
                , initXdpi);
    }

DefaultAutoAdaptStrategy#applyAdapt

//如果 target 实现 CancelAdapt 接口表示放弃适配, 所有的适配效果都将失效
if (target instanceof CancelAdapt) {
  LogUtils.w(String.format(Locale.ENGLISH, "%s canceled the adaptation!", target.getClass().getName()));
  AutoSize.cancelAdapt(activity);
  return;
}

问题,density是以Activity为单位的吗?

第三方页面取消适配

原理同上

参考文章

使用ContentProvider初始化你的Library

骚年你的屏幕适配方式该升级了!-今日头条适配方案

 类似资料: