背景
隨著華為、小米等廠商在平板赎瞎、折疊手機(jī)上的發(fā)力牌里,Android對多窗口(分屏模式)、自由窗口务甥、畫中畫等功能的支持牡辽,在大屏設(shè)備上的體驗(yàn)越來越好,國內(nèi)市場份額也已占據(jù)首位敞临,越來越多的產(chǎn)品針對大屏設(shè)備進(jìn)行相應(yīng)的適配處理态辛,為用戶帶來更好的體驗(yàn)。
數(shù)據(jù)分析報告
大屏設(shè)備主要體現(xiàn)在平板電腦和折疊屏手機(jī)挺尿,出貨量和占比等分析報告如下:
-
國內(nèi)平板電腦市場分額統(tǒng)計(jì)表(數(shù)據(jù)來源鏈接):
-
折疊屏手機(jī)發(fā)展趨勢分析表(數(shù)據(jù)來源鏈接):
數(shù)據(jù)表明:國內(nèi)Android平板已占據(jù)主流奏黑,折疊屏銷量日益增長,做好大屏設(shè)備的適配工作可提高較多用戶的使用體驗(yàn)票髓。
產(chǎn)品是否需要對大屏設(shè)備做出適配攀涵,怎樣選擇合適的適配方案?
產(chǎn)品需要對大屏設(shè)備做適配洽沟。
Android屏幕尺寸數(shù)不勝數(shù)以故,開發(fā)過程中要盡可能全的考慮UI的適配,應(yīng)有一套適合自己產(chǎn)品的UI適配規(guī)范裆操。-
對于平板和折疊屏的適配方案有很多怒详,開發(fā)成本受方案影響較大炉媒。選擇的方案不但會影響用戶體驗(yàn),還會導(dǎo)致后期的開發(fā)和維護(hù)成本昆烁。
選擇哪種方案應(yīng)根據(jù)該產(chǎn)品和大屏設(shè)備的用戶量來定吊骤,總結(jié)三個方向:根據(jù)產(chǎn)品類別、根據(jù)用戶群里静尼、分析行業(yè)競品- 根據(jù)產(chǎn)品的方向來定:
視頻類的APP用戶操作習(xí)慣更多的是橫屏操作白粉,絕大多數(shù)頁面需要考慮橫屏下的布局,這類APP可采用響應(yīng)式布局或開發(fā)HD版本來提升體驗(yàn)鼠渺。
新聞鸭巴、音樂、短視頻和購物類APP拦盹,用戶更習(xí)慣于豎屏操作鹃祖,可采用部分特殊頁面適配橫屏布局,其他頁面兼容橫屏普舆,防止橫豎屏切換UI變形恬口。
游戲類APP,往往需要固定方向等沼侣。 - 根據(jù)大屏設(shè)備的用戶量來定:
如開發(fā)前可以根據(jù)用戶使用的設(shè)備進(jìn)行埋點(diǎn)祖能,統(tǒng)計(jì)平板等大屏設(shè)備和普通手機(jī)的用戶,根據(jù)他們的設(shè)備ID和uid等信息华临,來統(tǒng)計(jì)占比芯杀。
如占比很小且用戶量很少,則可以對橫屏進(jìn)行簡單的適配雅潭。
如占比很大或用戶群體多揭厚,可再進(jìn)一步分析用戶的操作習(xí)慣,如是橫屏狀態(tài)下多還是豎屏狀態(tài)下的多扶供,甚至可以具體到某一頁面筛圆。根據(jù)統(tǒng)計(jì)的數(shù)據(jù)可決定該頁面是否需要出一套橫屏布局。 - 分析競品椿浓。對齊行業(yè)標(biāo)準(zhǔn)太援,用戶習(xí)慣已養(yǎng)成,方案成熟扳碍。
- 根據(jù)產(chǎn)品的方向來定:
/**
* 判斷是否是平板設(shè)備
*/
public boolean isTablet() {
return (getResources().getConfiguration().screenLayout
& Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE;
}
/**
* 判斷屏幕方向
*/
public boolean isScreenOriatationPortrait(Context context) {
return context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT;
}
競品分析
適配方案總結(jié):
適配方案 | 描述 | 優(yōu)點(diǎn) | 缺點(diǎn) |
---|---|---|---|
HD版本 | 針對平板設(shè)備重新開發(fā)維護(hù)一個APP | 針對性強(qiáng)提岔、更靈活使得體驗(yàn)更好,可跟APP隔離開 | 開發(fā)笋敞、設(shè)計(jì)和維護(hù)成本大 |
響應(yīng)式布局 | 基于同一套代碼碱蒙,開發(fā)一個APP能夠兼容多尺寸、多終端設(shè)備的顯示,能夠動態(tài)調(diào)整頁面的布局及容器的布局 | 后期迭代速度快且成本低 | 前期開發(fā)成本高赛惩、改造頁面多且復(fù)雜哀墓、沒有標(biāo)準(zhǔn)屬于摸索階段 |
橫屏固定寬度 | 根據(jù)屏幕方向控制屏幕旋轉(zhuǎn),橫屏狀態(tài)下固定寬度喷兼,兩邊留白 | 入侵性小篮绰、維護(hù)成本低 | 沒有找到采用類似方案的競品、與系統(tǒng)自帶的平行視界樣式相重季惯、無法適配多窗口模式 |
平行視界 | pad等大屏設(shè)備自帶的功能吠各,部分手廠商的設(shè)備可靈活配置屏幕是否可調(diào)節(jié)、分屏模式等 | 設(shè)備推薦勉抓、入侵性低走孽、成本低、可分屏并自由調(diào)節(jié)分屏大小 | 各家設(shè)備對于平行視界的處理參差不齊琳状,橫豎屏切換操作偶爾會顯示異常不居中 |
以上方案可結(jié)合屏幕限定符一起結(jié)合使用
最小寬度值與典型屏幕尺寸的對應(yīng)關(guān)系:
320dp:典型手機(jī)屏幕(240x320 ldpi、320x480 mdpi盒齿、480x800 hdpi 等)念逞。
480dp:約為 5 英寸的大手機(jī)屏幕 (480x800 mdpi)。
600dp:7 英寸平板電腦 (600x1024 mdpi)边翁。
720dp:10 英寸平板電腦(720x1280 mdpi翎承、800x1280 mdpi 等)
最小寬度限定符:
res/layout/main_activity.xml
res/layout-sw600dp/main_activity.xml # 屏幕寬度至少600dp用該布局
可用寬度限定符:
res/layout/main_activity.xml
res/layout-w600dp/main_activity.xml #寬度超過600dp的屏幕
屏幕方向限定符:
res/layout/main_activity.xml # 手機(jī)
res/layout-land/main_activity.xml # 手機(jī)-橫屏
res/layout-sw600dp/main_activity.xml # 7英寸平板
res/layout-sw600dp-land/main_activity.xml # 7英寸平板-橫屏
響應(yīng)式布局
可以根據(jù)屏幕具體的物理尺寸自適應(yīng)的顯示,只需要開發(fā)一套代碼符匾,就可以兼容多種尺寸的終端叨咖,不需要開發(fā)單獨(dú)的HD版本
將界面遷移到自適應(yīng)布局
優(yōu)酷響應(yīng)式布局技術(shù)全解析
Android 與 Chrome OS 中針對大屏幕設(shè)備的更新
Android大屏應(yīng)用質(zhì)量要求
activity 嵌入
為可折疊設(shè)備構(gòu)建應(yīng)用
Android 折疊屏技術(shù)發(fā)展與適配
華為折疊屏應(yīng)用開發(fā)指導(dǎo)
橫屏設(shè)置固定寬度
- 設(shè)置activity旋轉(zhuǎn)不被銷毀:
<activity
android:name=".xx.xxActivity"
android:configChanges="keyboardHidden|smallestScreenSize|orientation|screenSize"
android:launchMode="xx"/>
screenLayout | 屏幕的顯示發(fā)生了變化---不同的顯示被激活 |
---|---|
orientation | 屏幕方向改變了---橫豎屏切換 |
screenSize | 屏幕大小改變了 |
smallestScreenSize | 屏幕的物理大小改變了,如:連接到一個外部的屏幕上 |
- 創(chuàng)建屏幕旋轉(zhuǎn)的工具類:
public class ScreenRotatingManager {
/**
* 用戶關(guān)閉重力感應(yīng)
*/
private static final int USER_CLOSE_GRAVITY = 0;
/**
* 屏幕旋轉(zhuǎn)角度閾值
*/
private int mRotationAngle = 50;
private OrientationSensorListener mListener;
private SensorManager mSensorManager;
private Sensor mSensor;
private Activity mActivity;
/**
* 是否開啟重力感應(yīng)
*/
private int isOpenRotation;
public ScreenRotatingManager(Context context) {
mSensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
mSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
mListener = new OrientationSensorListener();
mSensorManager.registerListener(mListener, mSensor, SensorManager.SENSOR_DELAY_UI);
}
/**
* 開始監(jiān)聽
*/
public void startSensor(Activity activity) {
mActivity = activity;
if (mSensorManager != null && mListener != null && mSensor != null && mActivity!=null) {
mSensorManager.registerListener(mListener, mSensor, SensorManager.SENSOR_DELAY_UI);
mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}
}
/**
* 停止監(jiān)聽
*/
public void stopSensor() {
if (mSensorManager != null && mListener != null && mActivity!=null) {
mSensorManager.unregisterListener(mListener);
mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}
}
/**
* 釋放傳感器單利
*/
public void releaseSensor(){
if (mActivity !=null){
mActivity = null;
}
if (mListener !=null){
mListener = null;
}
if (mSensorManager!=null){
mSensorManager = null;
}
if (mSensor!=null){
mSensor = null;
}
}
/**
* 傳感器屏幕方向監(jiān)聽
*/
public class OrientationSensorListener implements SensorEventListener {
private static final int _DATA_X = 0;
private static final int _DATA_Y = 1;
private static final int _DATA_Z = 2;
public static final int ORIENTATION_UNKNOWN = -1;
private int mLastAngleStamp = 0;
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
@Override
public void onSensorChanged(SensorEvent event) {
if (mActivity == null){
return;
}
//判斷手機(jī)重力感應(yīng)是否開啟啊胶,0關(guān)閉甸各,1開啟
try {
isOpenRotation = Settings.System.getInt(mActivity.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION);
} catch (Settings.SettingNotFoundException e) {
e.printStackTrace();
}
if (isOpenRotation == USER_CLOSE_GRAVITY) {
return;
}
float[] values = event.values;
int orientation = ORIENTATION_UNKNOWN;
float X = -values[_DATA_X];
float Y = -values[_DATA_Y];
float Z = -values[_DATA_Z];
float magnitude = X * X + Y * Y;
// Don't trust the angle if the magnitude is small compared to the y value
if (magnitude * 4 >= Z * Z) {
//1弧度的值 : 180 / 3.1415926
float oneEightyOverPi = 57.29577957855f;
float angle = (float) Math.atan2(-Y, X) * oneEightyOverPi;
orientation = 90 - (int) Math.round(angle);
// normalize to 0 - 359 range
while (orientation >= 360) {
orientation -= 360;
}
while (orientation < 0) {
orientation += 360;
}
}
//轉(zhuǎn)動條件,間隔大于1秒且旋轉(zhuǎn)角度大于50度(不舍時間手動轉(zhuǎn)屏屏幕會馬上恢復(fù)到手機(jī)的方向)
if (Math.abs(mLastAngleStamp - orientation) > mRotationAngle) {
mLastAngleStamp = orientation;
setScreenOrientation(orientation);
}
}
}
private void setScreenOrientation(int orientation) {
if (orientation > 45 && orientation < 135) {
mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE);
} else if (orientation > 135 && orientation < 225) {
mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
} else if (orientation > 225 && orientation < 315) {
mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
} else if ((orientation > 315 && orientation < 360) || (orientation > 0 && orientation < 45)) {
mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}
}
}
- 在基類中的onCreate()和onConfigurationChanged()方法里根據(jù)屏幕方向焰坪,動態(tài)設(shè)置屏幕寬度趣倾,橫屏狀態(tài)下設(shè)置寬度 = 豎屏狀態(tài)下的寬,居中顯示
//更改窗口寬度
getWindowManager().getDefaultDisplay().getMetrics(metrics);
WindowManager.LayoutParams lp = getWindow().getAttributes();
int width = verticalScreenWidth;
lp.width = width;
getWindow().setAttributes(lp);
//更改根布局寬度
FrameLayout root = findViewById(android.R.id.content);
root.getLayoutParams().width = verticalScreenWidth;
LinearLayout.LayoutParams rootLayoutParams = (LinearLayout.LayoutParams) root.getLayoutParams();
rootLayoutParams.gravity = Gravity.CENTER_HORIZONTAL;
root.getRootView().setBackgroundColor(getResources().getColor(R.color.black));
root.setLayoutParams(rootLayoutParams);
平行視界
各廠商系統(tǒng)自帶的平行視界某饰,默認(rèn)為打開狀態(tài)(華為需要配置文件開啟)儒恋,設(shè)置中可控制開啟或關(guān)閉該模式,具體路徑:
小米:設(shè)置 - 應(yīng)用設(shè)置 - 橫屏模式(自動開啟)21年下半年平板適配說明也有平行窗口配置方式黔漂,目前小米文檔中已刪除該條目诫尽。配置方法同華為一樣
,文檔地址:小米平板適配說明炬守、未刪除平行窗口適配的文檔(視頻中):小米平板5平行視界適配教程牧嫉;
華為:設(shè)置 - 應(yīng)用 - 平行視界(需要配置:平行視界配置方法)華為可靈活配置頁面是否可分屏、分屏模式劳较、屏幕后是否可拖動調(diào)節(jié)寬度等配置
驹止。
- 修改AndroidManifest.xml內(nèi)application中新增meta-data浩聋;
<meta-data android:name="EasyGoClient" android:value="true" />
- 在assets目錄下新建配置文件easygo.json:配置模版。
logicEntities.body.mode:- 0:購物模式臊恋,activityPairs節(jié)點(diǎn)不生效
- 1:自定義模式(包括導(dǎo)航欄模式)
- -1:不啟用分屏(華為文檔中沒標(biāo)注衣洁,小米舊版文檔標(biāo)注過,同樣適用華為平板)
- 卸載重裝APP抖仅、不生效可以殺進(jìn)程坊夫、重啟pad試一下!
//判斷是否在平行視界
Configuration configurationStr = getResources().getConfiguration().toString();
Log.i(TAG, "是否在華為平行視界:" + configurationStr.contains("hwMultiwindow-magic"));
Log.i(TAG, "是否在華為平行視界:" + configurationStr.contains("hw-magic-windows"));
Log.i(TAG, "是否在小米平行視界:" + configurationStr.contains("miui-magic-windows"));
配置平行視界后效果:
多窗口模式:Android 允許多個應(yīng)用同時共享同一屏幕
用戶體驗(yàn)取決于 Android 操作系統(tǒng)的版本和設(shè)備類型:
Android7.0以上支持分屏模式撤卢,可通過指定activity允許的最小尺寸來處理多窗口的顯示方式环凿。
通過設(shè)置manifest文件中的Application或Activity屬性,添加resizeableActivity="true | false" 確定應(yīng)用是否可以動態(tài)改變尺寸放吩,為自己的應(yīng)用開啟或停用多窗口顯示(同樣停用小窗模式)智听。
Android12將多窗口模式作為標(biāo)準(zhǔn)行為,具體參考:多窗口模式說明
如下圖:應(yīng)用在最近使用界面中長按某個應(yīng)用進(jìn)入
左上圖:設(shè)置了禁止動態(tài)改變尺寸(resizeableActivity = false)渡紫,無法分屏或小屏到推;
右上圖:設(shè)置了resizeableActivity = true;可以開啟分屏或小屏惕澎;
左下圖:開啟了分屏模式分屏需注意生命周期的變化莉测,參考
多窗口模式說明 ;
右下圖:開啟了小屏模式唧喉;
//是否在多窗口模式
boolean isInMultiWindowMode = isInMultiWindowMode();
/**
* 開啟分屏模式的回調(diào)
* @param isInMultiWindowMode
* @param newConfig
*/
@Override
public void onMultiWindowModeChanged(boolean isInMultiWindowMode, Configuration newConfig) {
super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig);
//當(dāng)isInMultiWindowMode為true時捣卤,表示進(jìn)入分屏或小窗
}
//android N版本API Intent類新增Flag:FLAG_ACTIVITY_LAUNCH_ADJACEN,該Flag僅用于多窗口下的分屏模式(split-screen)八孝;
Intent intent = new Intent(context, LoginActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT);
context.startActivity(intent);
//Android N版本API新增layout布局,對layout屬性值進(jìn)行設(shè)置,可配置activity的顯示位置和窗口大小董朝。
<activity android:name=".MyActivity">
<layout android:defaultHeight="500dp"
android:defaultWidth="600dp"
android:gravity="top|end"
android:minHeight="450dp"
android:minWidth="300dp" />
</activity>
如在橫屏狀態(tài)下需要調(diào)整列表顯示個數(shù)可以在onConfigurationChanged()
中重新設(shè)置recyclerView
的列數(shù):recyclerView.setLayoutManager(GridLayoutManager(this, newColumn));
大屏設(shè)備適配改造方案(主要考慮橫豎屏切換)
今日頭條適配方案介紹:
推薦文章:
今日頭條適配方案
頭條方案框架GitHub
- 優(yōu)點(diǎn):入侵性小成本極低,可根據(jù)不同尺寸的設(shè)備等比縮放元素干跛,使得在不同設(shè)備上露出的內(nèi)容一致益涧;
- 缺點(diǎn)1:當(dāng)某個系統(tǒng)控件或三方庫控件的設(shè)計(jì)圖尺寸和和我們項(xiàng)目自身的設(shè)計(jì)圖尺寸差距非常大時,這個問題就越嚴(yán)重驯鳖;
- 缺點(diǎn)2:橫豎屏切換后導(dǎo)致view元素大小不一闲询;
- 對于固定方向的應(yīng)用,手機(jī)設(shè)備開啟頭條適配方案浅辙。在平板上某些廠商默認(rèn)開啟了平行窗口模式扭弧,應(yīng)用可橫豎屏切換和分屏等,為了避免修改密度導(dǎo)致UI元素錯亂记舆,判斷平板設(shè)備就禁用頭條適配鸽捻。
/**
* 判斷是否為平板設(shè)備
*/
public boolean isTablet() {
return (getResources().getConfiguration().screenLayout
& Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE;
}
- 盡可能使用約束布局:約束布局規(guī)范文檔
適配場景最多最明顯的是列表,如一行兩列、三列等的列表御蒲,屏幕方向變化后item要么被拉寬要么就被擠壓衣赶。- 建議item的布局元素寬高設(shè)置0dp,與父布局建立約束條件厚满,設(shè)置寬高比府瞄,注意?一定不能把寬高寫死,否則會導(dǎo)致寬高比失效碘箍。
- 不建議獲取屏幕寬度遵馆,減去間隔再均分item,這樣會造成屏幕方向改變后item的尺寸沒發(fā)生變化丰榴,導(dǎo)致顯示異常货邓。
- 設(shè)置
LayoutManager
,給每行定義item的個數(shù)四濒。 - 可以在布局中設(shè)置間距换况。給列表添加
ItemDecoration
時需要計(jì)算好每個view的偏移量。因?yàn)樵?code>ItemDecoration的getItemOffsets()
方法中設(shè)置的偏移量是作用在每個view元素上的盗蟆,如果item中的view設(shè)有比例复隆,每個元素增加的偏移量不同會導(dǎo)致view大小不均等。 - 在RecyclerView.Adapter中的onAttachedToRecyclerView()方法中設(shè)置跨度姆涩,根據(jù)不同的Type設(shè)置每個類型item的跨度,這時需要配置適合自己的ItemDecoration防止item尺寸不均等惭每。
代碼如下:
//每行三列
GridLayoutManager manager = new GridLayoutManager(getContext(), 3);
recyclerView.setLayoutManager(manager);
/**
* 直播類型的item跨度為3骨饿,其他的跨度為1
* 跨度設(shè)置為3,所以直播item一行一列台腥,其他的一行三列
* @param recyclerView
*/
@Override
public void onAttachedToRecyclerView(RecyclerView recyclerView) {
super.onAttachedToRecyclerView(recyclerView);
RecyclerView.LayoutManager manager = recyclerView.getLayoutManager();
if (manager instanceof GridLayoutManager) {
final GridLayoutManager gridManager = ((GridLayoutManager) manager);
gridManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
@Override
public int getSpanSize(int position) {
int itemType = getItemViewType(position);
if (itemType == LIVE_ROOM_TYPE) {
return 3;
} else {
return 1;
}
}
});
}
}
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
RecyclerView.LayoutManager lm = parent.getLayoutManager();
ViewGroup.LayoutParams lp = view.getLayoutParams();
boolean isGrid = lm instanceof GridLayoutManager && lp instanceof GridLayoutManager.LayoutParams;
if (!isGrid) {
return;
}
int spanCount = ((GridLayoutManager) lm).getSpanCount();
int spanIndex = ((GridLayoutManager.LayoutParams) lp).getSpanIndex();
int spanSize = ((GridLayoutManager.LayoutParams) lp).getSpanSize();
if (spanSize == spanCount) {
//占滿一整行的不設(shè)置邊距
outRect.set(0, 0, 0, 0);
} else if (spanCount / spanSize == 3) {
//一行三列
int l = 0, r = 0;
if (spanIndex == 0) {
//最左側(cè)
l = dp11;
r = dp3;
} else if (spanCount % (spanIndex + 1) == 1) {
//中間
l = dp7;
r = dp7;
} else if (spanCount % (spanIndex + 1) == 0) {
//最右側(cè)
l = dp3;
r = dp11;
}
outRect.set(l, 0, r, 0);
}
}
<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="wrap_content">
<ImageView
android:id="@+id/background"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/shape_corner_9"
android:scaleType="centerCrop"
android:src="@drawable/bg_small_placeholder"
app:layout_constraintDimensionRatio="h,1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
未完待續(xù)...