前言
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適配方案
騷年你的螢幕適配方式該升級了!-今日頭條適配方案