Android螢幕適配方案

houziershi發表於2018-12-05
前言

android裝置各種各樣,手機、pad、電視、車載等不一而足。即使是相同解析度的手機也可能引數不一致,比如1080P的手機 dpi 一般認為是480,但是 Google 的Pixel2(1920*1080)的 dpi 是420。此外,android裝置的寬高比更是多種多樣。這就導致App適配的工作異常困難。尤其是你的app要適配各種平臺,比如手機、pad、車載、電視。在這種情形下,你面臨的問題讓你無所適從,因為你根本猜不到裝置的引數和尺寸,更別提如何適配。

相關知識

android度量計算公式

  • px = density * dp
  • density = dpi / 160
  • px = dp * (dpi / 160)
  • DisplayMetrics.density
  • DisplayMetrics.densityDpi
  • DisplayMetrics.scaledDensity

具體的含義自行搜尋,density 的差異導致適配困難;scaledDensity 是字型的縮放因子,scaledDensity 正常情況下和 density 相等,但是調節系統字型大小後會改變這個值。
檢視原始碼,可以得知:DisplayMetrics 例項通過 Resources#getDisplayMetrics可以獲得,而Resouces通過 Activity 或者 Application 的 Context 獲得。
dp 和 px 的轉換是通過 DisplayMetrics 中相關的值來計算的,view、bitmap 等元素在計算中的dp轉換也是如此。
佈局檔案中 dp 的轉換,最終都是呼叫 TypedValue#applyDimension(int unit, float value, DisplayMetrics metrics) 來進行轉換。類似的,BitmapFactory#decodeResourceStream 方法也會應用 DisplayMetrics 中的引數計算。

    /**
     * Converts an unpacked complex data value holding a dimension to its final floating 
     * point value. The two parameters <var>unit</var> and <var>value</var>
     * 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;
    }
複製程式碼
今日頭條方案
原理
  • density = px / dp
適配方案
  • 給定一個寬高大小固定的標準設計圖,支援以寬或高一個維度自適應適配,保持改維度和設計圖一致;
  • 支援dp和sp單位。
實現

修改application和activity的density,系統修改字型時開啟App也能對應修改。scaledDensity計算根據系統原來的比值來獲得現在修改後的值。

final float targetScaledDensity = targetDensity * (appDisplayMetrics.scaledDensity / appDisplayMetrics.density);
複製程式碼

在 Activity#onCreate 方法中呼叫下。程式碼比較簡單,也沒有涉及到系統非公開api的呼叫,因此理論上不會影響app穩定性。

   /**
     * 頭條處理多裝置的方案  setCustomDensity(this, getApplication());
     *
     * @param activity
     * @param application
     */
    private void setCustomDensity(Activity activity, final Application application) {

        //application
        final DisplayMetrics appDisplayMetrics = application.getResources().getDisplayMetrics();

        if (sRoncompatDennsity == 0) {
            sRoncompatDennsity = appDisplayMetrics.density;
            sRoncompatScaledDensity = appDisplayMetrics.scaledDensity;
            application.registerComponentCallbacks(new ComponentCallbacks() {
                @Override
                public void onConfigurationChanged(Configuration newConfig) {
                    if (newConfig != null && newConfig.fontScale > 0) {
                        sRoncompatScaledDensity = application.getResources().getDisplayMetrics().scaledDensity;
                    }
                }

                @Override
                public void onLowMemory() {

                }
            });
        }

        //計算寬為360dp 同理可以設定高為640dp的根據實際情況
        final float targetDensity = appDisplayMetrics.widthPixels / 360;
        final float targetScaledDensity = targetDensity * (sRoncompatScaledDensity / sRoncompatDennsity);
        final int targetDensityDpi = (int) (targetDensity * 160);

        appDisplayMetrics.density = targetDensity;
        appDisplayMetrics.densityDpi = targetDensityDpi;
        appDisplayMetrics.scaledDensity = targetScaledDensity;

        //activity
        final DisplayMetrics activityDisplayMetrics = activity.getResources().getDisplayMetrics();

        activityDisplayMetrics.density = targetDensity;
        activityDisplayMetrics.densityDpi = targetDensityDpi;
        activityDisplayMetrics.scaledDensity = targetScaledDensity;
    }
複製程式碼

具體適配效果可以見參考資料連結,今日頭條團隊在各種手機上的適配效果和後期bug反饋。

思考一刻

這個方案的解決思路很簡潔,參考資料也詳細的列舉了它的優點,非常吸引人。但是,最終我們公司的專案沒有采用這個,而是採用下面的方案。理由很簡單:一次修改,全域性改變。後期維護無所適從。假如一處UI出問題了,你打算怎麼改?你沒法改,你怎麼改。

後續

參考中《騷年你的螢幕適配方式該升級了!-今日頭條適配方案》這篇文章進一步升級了這個思路,它最大的貢獻是對單個 Activity 或 Fragment 可以取消適配。這個思路可以解決後期維護問題,我覺得這個方案這個時候就值得推薦和使用了。同時,它還能自定義以寬或者高為維度進行適配。

AndroidScreenAdaptation 方案
原理
  • 基於設計圖的寬度值(或高度值)和對應的dpi適配,即根據裝置的實際寬度(或高度)相對應的縮放view的尺寸。
  • 縮放比率 = value * ((float) actualWidth / (float) designWidth)
適配方案
  • 給定一個寬高大小固定的標準設計圖,支援以寬或高一個維度自適應適配,保持改維度和設計圖一致;
  • 支援dp和sp單位。
實現

遍歷 ViewGroup 獲取所有子 View 的尺寸引數,重新計算 View 的WidthHeightFont、Padding、LayoutMargin。

/**
 * Only adapter width/height/padding/margin
 * Created by zhangyuwan0 on 2018/3/21.
 */

public class SimpleConversion implements IConversion {

    @Override
    public void transform(View view, AbsLoadViewHelper loadViewHelper) {
        if (view.getLayoutParams() != null) {
            loadViewHelper.loadWidthHeightFont(view);
            loadViewHelper.loadPadding(view);
            loadViewHelper.loadLayoutMargin(view);
        }
    }

}
複製程式碼

Activity、Fragment、自定義 View 等載入view後,手動呼叫LoadViewHelper#loadView 方法重計算一遍所有view。本質的轉化方法是計算縮放因子。

    private float calculateValue(float value) {
        if ("px".equals(unit)) {
            return value * ((float) actualWidth / (float) designWidth);
        } else if ("dp".equals(unit)) {
            int dip = dp2pxUtils.px2dip(actualDensity, value);
            value = ((float) designDpi / 160) * dip;
            return value * ((float) actualWidth / (float) designWidth);

        }
        return 0;
    }
複製程式碼
專案實際反饋
  • 簡單衡量頭條和AndroidScreenAdaptation的優缺點後,我們最終選擇這個方案。原因:雖然所有佈局都需要手動呼叫 ScreenAdapterTools # getInstance() # loadView(view) 方法,工作量大;但是,優點也是這個。任何 View 的適配都是可以調整和修改的,而且不會影響其它佈局。
    /**
     * Created by guokun on 2018/7/21.
     * Description: 標準寬高640x360(16:9) density = 1.0 dpi = 160
     * 1. 高度低於設計高度,以高度作標準縮放;
     * 2. 高度高於設計高度,但是高度:寬度 < 9:16,以高度作標準縮放;
     * 3. 其餘以寬度作標準縮放;
     * @param
     * @return
     */
    public float calculateValue(float value) {
        if ("px".equals(unit)) {
            return value * ((float) actualWidth / (float) designWidth);
        } else if ("dp".equals(unit)) {
            int dip = dp2pxUtils.px2dip(actualDensity, value);
            value = ((float) designDpi / 160) * dip;

            if (actualHeight < designHeight || actualWidth * designHeight / designWidth > actualHeight) {
                return value * ((float) actualHeight / (float) designHeight);
            }
            return value * ((float) actualWidth / (float) designWidth);
        }
        return 0;
    }
複製程式碼
  • 自定義 View 基本不支援。每個自定義 View 你需要檢視原始碼呼叫 calcualteValue 重新計算引數。幸運的是專案自定義 View 不是很多和複雜。最致命的是:wrapcontent 不適配,所有的 View 必須給定尺寸;SeekBarProgress 不支援,手動反射呼叫方法處理適配問題。
 @Override
    public void transform(View view, AbsLoadViewHelper loadViewHelper) {
        /**Created by guokun on 2018/7/28.
         * Description: MyLinearLayout_h381特殊處理
         * 1. MyLinearLayout鍵盤的高度大於標準高度360;
         * 2. 這裡UI標準圖設計bug,未加上20dp 鍵盤top;
         * */
        if (view.getTag() != null && (Integer)view.getTag() == MyLinearLayout_h381.getCustomHeight(view.getContext())) {
            int defaultDesign = loadViewHelper.getDesignHeight();
            loadViewHelper.setDesignHeight((Integer) view.getTag());
            if (view.getLayoutParams() != null) {
                loadViewHelper.loadWidthHeightFont(view);
                loadViewHelper.loadPadding(view);
                loadViewHelper.loadLayoutMargin(view);
            }
            loadViewHelper.setDesignHeight(defaultDesign);
        }else {
            if (view.getLayoutParams() != null) {
                loadViewHelper.loadWidthHeightFont(view);
                loadViewHelper.loadPadding(view);
                loadViewHelper.loadLayoutMargin(view);
            }
        }
    }
複製程式碼
思考一刻

放棄這個專案吧,不值得。光是缺點,你都改不過來。

大總結

android 適配一直是個懸而未決的大難題。Google 提供的思路對於國內複雜的裝置環境和小團隊而言,代價很高。綜合專案實際場景再權衡各種方案才是解決之道,因為這些方案本身並不是很大的工程。

參考資料

推薦Android兩種螢幕適配方案
Android 目前穩定高效的UI適配方案
騷年你的螢幕適配方式該升級了!-今日頭條適配方案

相關文章