手机屏幕目前的整体发展趋势是大屏化,大屏化主要表现在两方面:屏幕面积的增大,屏占比的提高。但是目前这两方面已经发展的相当成熟,很难再有大的突破。
手机由功能手机向智能手机演变最重要的标志是屏幕的变化。2010年智能手机的平均屏幕尺寸仅有3.1英寸,发展至2014年智能手机的屏幕平均尺寸增加至4.8英寸,2018年更是增加至5.9英寸,目前主流的大屏尺寸已达到6.5寸左右,智能手机逐渐朝着大屏化方向发展。
单从屏幕面积来看,6.5英寸左右已是单手操控和携带便捷性的极限。
手机厂商通过陆续推出刘海屏、水滴屏、挖孔屏、全面屏、升降式摄像头、屏下指纹识别等功能,将屏幕可用面积利用到了极致,极大的提升了屏占比。现阶段大多数手机的屏占比处于**90%**左右,很难再有大的提升空间。
折叠屏手机的出现不仅实现了屏幕尺寸增加,同时还满足携带方便的需求,有效解决大屏和便携矛盾,未来或将成为手机发展的重要方向之一。
折叠屏的主要结构由柔性AMOLED屏与铰链组成。
通常,我们见到的折叠屏手机都是由1整块AMOLED屏组成,并且铰链位置可以显示画面。
除此之外,目前市面还有一种双屏手机(屏幕由2块组成,铰链区域不可显示画面),也被称为折叠屏手机,即微软2020年9月推出的基于Android 10.0系统的Surface Duo。
目前折叠屏的折叠方式主要由以下4种,主流的实现方式是:横向内折与横向外折
数据来源:头豹研究院:2020年中国折叠屏手机行业概览
2020国内智能手机销量约3亿部,而同期全球的折叠屏手机出货量仅194.73 万部,三星以**71.59%的市场份额占据全球折叠屏市场第一,华为市场份额10.56%**位居第二。
从销量来说,折叠屏市场占有依然比较小众,主要原因在于折叠屏手机目前良品率还不够高,产能受限,进而导致手机售价过高;同时,软件方面,大部分软件还未进行完全适配,影响用户体验。
技术成熟度低
良品率低
柔性AMOLED比LCD良率低。华为Mate X 的AMOLED屏供应商为京东方,京东方折叠屏目前的良品率数据仅为:2018,65%;2021,85%
价格制约销量
当前市场高端机定位6k;折叠屏初期产品普遍售价1w以上,目前部分产品最低售价刚降到万元内。
2021 Google I/O 上发布的折叠屏适配新方案,代表Android对折叠屏设备适配的重视,在大屏化的趋势下,折叠屏未来可期。
只要应用适配了折叠屏设备,同时就自动适配了Android 平板等大屏设备。
根据Google发布的数据,通过适配 Android 大屏设备,开发者们可以覆盖超过 2.5 亿台活跃的可折叠设备、平板电脑和 Chromebook。2020 年,平板电脑设备的销售量增长了 16%。分析师预计,到 2023 年市面上将有超过 4 亿台 Android 平板电脑,到2023年可折叠设备销量将达到3000万台,增长空间巨大。另外,可折叠设备也正在重新定义高端设备。Android 应用也可以在 Chrome OS 上运行,而 Chrome OS 现在是世界第二大桌面操作系统。
折叠屏手机适配主要经历了三个重要阶段。
折叠屏手机首次发布,Android官方折叠屏适配指南
2018年8月Google发布Android 9.0,首次支持折叠屏功能,并推出了Android官方折叠屏适配指南。主要从以下几个方面进行适配:
resizeableActivity
与 maxAspectRatio
厂商补充适配方案
各厂商陆续发布自己的折叠屏手机,以及补充的适配方案。不同折叠屏手机上,拥有各自研发的一些独有功能,要想体验这些独特功能,就需要使用厂商的方案进行适配。
代表:三星、华为、Microsoft Surface Duo等。
Google 2021 I/O 发布Jetpack WindowManager
等库
2021年5月,Google I/O开发者大会推出专门用于折叠屏适配的库 Jetpack WindowManager
1.0,以及更新了已有的一些库如:SlidingPaneLayout
、NavRail
等,以便应用更便捷的适配折叠屏功能。
Jetpack WindowManager
1.0,专门用于适配折叠屏手机,获取折叠屏相关信息。SlidingPaneLayout
1.2,支持双窗格布局。NavRail
,实现垂直导航栏。主要参考 官方文档
在默认情况下,当屏幕发生了变化(这里指折叠屏折叠变化时,或应用从一个屏幕转到另一个屏幕),系统会销毁并重新创建整个 Activity
。
但我们希望屏幕变化之后,程序能够以切换前的状态继续运行,不需要重启页面。我们可以给 Activity
添加configChanges
配置:
<activity
...
android:configChanges="smallestScreenSize|screenSize|screenLayout" />
应用内监听变化,根据当前窗口大小,调整布局大小和位置
override fun onConfigurationChanged(newConfig: Configuration) {
// 获取当前窗口配置信息,调整布局大小
}
如果需要重启 Activity
才能完成适配的场景,可以通过onSaveInstanceState()
与onRestoreInstanceState()
或 ViewModel对象
来进行之前状态保存和后续的恢复。
更多详细适配参考官方:保存界面状态 和 支持配置变更。
屏幕兼容性
resizeableActivity
用于声明是否支持多窗口模式和动态调整显示尺寸。
折叠屏时,需要让应用支持动态改变尺寸,我们需要在 menifest 中的 Application
或对应的 Activity
下声明属性:
android:resizeableActivity="true"
当系统编译设置target
>= 24(Android 7.0) 不需要手动设置,系统默认为 true
,支持多窗口和调节尺寸。
如果应用设置 resizeableActivity=false
,则会告知平台其不支持多窗口模式。系统可能仍会调整应用的大小或将其置于多窗口模式;但要实现兼容性,便需要对应用中的所有组件(包括应用的所有 Activity、Service 等)应用同一配置。在某些情况下,重大变更(例如,显示屏尺寸更改)可能会重启进程,而不会更改配置。这时需要支持折叠屏连续性(不重启Activity
),添加属性
<meta-data
android:name="android.supports_size_changes" android:value="true" />
若Activity 设置了 resizableActivity=false
以及 maxAspectRatio
。设备展开时,系统会将应用置于兼容模式,以此保持 Activity 配置、大小和宽高比。
当resizeableActivity=false
声明不支持多窗口时,使用maxAspectRatio、minAspectRatio 指定最小或最大纵横比,在设置的纵横比限制范围内,折叠屏情况下,会自动对尺寸进行调节,超出限制的进入兼容模式(黑边)。
为支持折叠屏,Android 系统增加了21:9(2.33,三星 Glaxy Fold 屏幕比例) 超大纵横比。
推荐纵横比设置范围:[1, 2.4],即1:1到12:5
而当resizableActivity=true
时设置maxAspectRatio
等无效
更多信息参考 官方文档:声明受限屏幕支持
更多多窗口功能参考:多窗口支持、多项恢复设计
在 Android 10(API 29)增加多项恢复功能:所有顶层的可聚焦 Activity
均处于 RESUMED
状态。涉及多窗口的行为变化历史:
Android 7.0 支持分屏:左右/上下显示两个窗口
Android 8.0 支持画中画,此时处于画中画的Activity
虽处于前台,但处于 Paused
状态
Android 9.0 (API 28) 及以下:多窗口下,只有获得焦点应用处于Resumed
状态,其它可见Activity
扔处于Paused
状态,如下图左侧
Android 10.0 (API 29) :多窗口模式时,个Acttivity
全部处于Resumed
状态
为解决Android 9.0及以下,只有获得焦点应用才处于Resume
状态,其它可见Activity
处于Paused
状态问题。可添加下列属性,手动添加开启多项恢复:
<meta-data
android:name="android.allow_multiple_resumed_activities" android:value="true" />
多窗口模式下,当Activity
获得/失去顶部位置状态时,会执行新增加的回调 Activity#onTopResumedActivityChanged(isTopResumed), isTopResumed
标识当前 Activity
是否处于多窗口模式下的最顶层。
当我们使用了独占资源时就要用到这个方法。什么叫独占资源?麦克风、摄像头就是,这类资源同一时间只能给一个 Activity
使用。以摄像头使用为例,在Android10上,官方建议使用 CameraManager.AvailabilityCallback#onCameraAccessPrioritiesChanged()监听摄像头是否可用。当收到Activity#onTopResumedActivityChanged(isTopResumed)
回调时,
isTopResumed = false
时,需要在此时判断是否释放独占资源,而不必在一失去焦点时就释放资源;isTopResumed = true
时 ,可以申请独占的摄像头资源,原持有摄像头资源的应用将收到 CameraDevice.StateCallback#onDisconnected() 回调后,对摄像头设备进行的后续调用将抛出 CameraAccessException多窗口下,支持跨应用数据拖拽功能,开始执行拖放操作时,来源应用必须设置 DRAG_FLAG_GLOBAL 标志,以示用户可以将数据拖动到其他应用,更多详情参考Android 官方文档:拖放
灵活布局
使用wrap_content
、match_parent
避免硬编码
使用 ConstraintLayout
左根布局,方便屏幕尺寸变化,视图自动移动和拉伸
备用布局
port
或 land
)单独适配图片资源:.9图、矢量图
更多详细适配参考官方文档:支持不同的屏幕尺寸
折叠屏厂商发布了对应适配文档的主要有:三星、华为、微软等。
三星折叠屏适配
主要参考三星折叠屏适配指导,基本同Android官方适配指南相差不大,部分需要独立适配的,只需针对三星屏幕信息进行适配即可。如折叠后纵横比 21 : 9
,展开纵横比:4.2 : 3
等。
微软Surface Duo双屏适配
在Android 官方适配指南的基础上,可再参考Surface官方:Surface Duo 开发人员文档 进行完全适配。Surfce不仅提供了其独有的Surface 双屏布局库独有功能,还有部分适用于 Surface Duo 的 Android 示例应用。
华为折叠屏适配官方文档
华为折叠屏应用开发指导 包括提供给UX同学的设计指引,以及RD同学的接入指南。以下会重点介绍下其独有的华为应用内分屏:平行视界功能
华为折叠屏UX设计指导,在折叠屏各种使用场景下,给出了设计建议,包括内容(文字1.2倍、图片、视频等)变化大小范围、页面布局设计、页面信息架构设计、多窗口交互设计、各类型应用的典型场景设计实现案例等。
除了Android 官方支持的多窗口模式(分屏,主要是多应用场景实现),华为还支持单应用方式来实现多窗口多任务。主要实现方案:悬浮窗、平行视界。
悬浮窗案例:比如 一步窗口,在特定场景下,点击控件/按钮/链接/附件等,直接打开一个任务悬浮窗,即为一步窗口。一步窗口主要适用于跨应用的跳转。
平行视界
平行视界以 Activity
为基本单位实现应用内分屏的系统侧解决方案。应用可以根据自身业务设计分屏显示Activity
组合,以实现符合应用逻辑的最佳单应用多窗口用户体验。提供以下两种基础分屏模式:
支持2种模式:导航栏、自定义
通用导航模式(0)
API 接入
新型非侵入式集成方式,无SDK;
在assets目录下新建配置文件easygo.json
修改AndroidManifest.xml内application中新增meta-data
<meta-data android:name="EasyGoClient" android:value="true" />
easygo.json
配置模板
Jetpack WindowManager
出现之前,折叠屏适配面临如下一些问题
折叠状态下应用体验较差:如半开状态、桌面模式等折叠姿态下,全屏画面扭曲。
无法正确识别折叠屏类型、可显示范围等。如微软的双屏折叠手机,中间部分无画面区域被铰链遮挡。
缺乏统一适配库支持,开发人员对于不同机型,适配比较繁琐,工作量较大。
…
为了帮助APP开发人员支持新的设备尺寸,并为新旧平台版本上的各种Window Manager
功能提供通用的API交互。Android官方推出了Jetpack WindowManager
,面向可折叠设备,将来的版本将扩展为支持更多的显示类型和窗口功能。
本文介绍的Jetpack WindowManager API 大部分基于:androidx.window:window:1.0.0-alpha09
,最新Release版本可从官网获取。
Jetpack WindowManager
利用TYPE_HINGE_ANGLE
传感器,获取折叠角度demo:
class SensorActivity : AppCompatActivity(), SensorEventListener {
private var sensorManager: SensorManager? = null
private var hingeAngleSensor: Sensor? = null
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_sensor)
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
hingeAngleSensor = sensorManager?.getDefaultSensor(Sensor.TYPE_HINGE_ANGLE)
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
LogUtil.d("onAccuracyChanged $sensor, $accuracy")
}
override fun onSensorChanged(event: SensorEvent) {
val angle = event.values[0]
LogUtil.d("onSensorChanged $event, $angle") // 获取角度值
}
override fun onResume() {
super.onResume()
hingeAngleSensor?.also { light ->
sensorManager?.registerListener(this, light, SensorManager.SENSOR_DELAY_NORMAL)
}
}
override fun onPause() {
super.onPause()
sensorManager?.unregisterListener(this)
}
}
Jetpack WindowManager
是一个以Kotlin
优先的现代化库,它支持不同形态的新设备,并提供 “类 AppCompat” 的功能以构建具有响应式 UI 的应用。
WindowManager
API 包含了以下内容:
WindowLayoutInfo
:包含了窗口的显示特性,例如该窗口是否可折叠或包含铰链
FoldingFeature
: 让您能够监听可折叠设备的折叠状态得以判断设备的姿态
WindowMetrics
: 提供当前窗口或全部窗口的显示指标
使用WindowManager适配的主要原理:通过WindowManager
获取折叠屏设备信息FoldingFeature
信息,对于不同的折叠姿态,更新UI显示内容。
FoldingFeature
用来描述屏幕的折叠状态或两个物理显示面板之间的铰链状态。主要的API功能如下:
getType()
:
TYPE_FOLD
。表示屏幕为无物理间隙的柔性屏幕中的折叠。市面上大部分常见的折叠屏,如Samsung Fold、华为 Mate X。TYPE_HINGE
。表示屏幕为带有铰链(不可显示区域)的两块屏幕,市面上目前比较少见,典型的指:Surface DuogetState()
:折叠状态,主要有:STATE_FLAT
(展开)、STATE_HALF_OPENED
(半开)两种状态。getBounds()
:折叠功能边界的 Rect 实例,指示折叠屏当前可显示的屏幕可见范围。getOrientation()
:折叠屏铰链防线:ORIENTATION_HORIZONTAL
、ORIENTATION_VERTICAL
。isSeparating()
:内容区域是否分割为2块。 true:半开或双屏设备;false:计算是否有遮挡屏幕半开并且铰链处于水平方向
fun isTableTopMode(foldFeature: FoldingFeature) =
foldFeature.isSeparating &&
foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
屏幕半开并且铰链处于垂直方向
fun isBookMode(foldFeature: FoldingFeature) =
foldFeature.isSeparating &&
foldFeature.orientation == FoldingFeature.Orientation.VERTICAL
获取折叠变化通知的2种方式:WindowManager
和 WindowInfoRepo
描述当前窗口的状态;监听窗口变化
getCurrentWindowMetrics()
:当前窗口状态。getMaximumWindowMetrics()
:当前系统的最大窗口状态。registerLayoutChangeCallback()
与 unregisterLayoutChangeCallback()
: 窗口变化注册、解注册监听主要实现步骤:
实例化WindowManager
实现回调接口
获取到:windowLayoutInfo,foldFeature
onStart
注册回调:传线程池参数、onStop
解注册
class FoldVerticalPageActivity : AppCompatActivity() {
private lateinit var binding: ActivityFoldVerticalPageBinding
private lateinit var windowManager: WindowManager
private val handler = Handler(Looper.getMainLooper())
private val mainThreadExecutor = Executor { r: Runnable -> handler.post(r) }
private val stateContainer = StateContainer()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
windowManager = WindowManager(this)
binding = ActivityFoldVerticalPageBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
}
override fun onStart() {
super.onStart()
windowManager.registerLayoutChangeCallback(mainThreadExecutor, stateContainer)
}
override fun onStop() {
super.onStop()
windowManager.unregisterLayoutChangeCallback(stateContainer)
}
inner class StateContainer : Consumer<WindowLayoutInfo> {
var lastLayoutInfo: WindowLayoutInfo? = null
override fun accept(newLayoutInfo: WindowLayoutInfo) {
for (displayFeature in newLayoutInfo.displayFeatures) {
LogUtil.d("displayFeature=$displayFeature")
val foldFeature = displayFeature as? FoldingFeature
foldFeature ?: continue
if (foldFeature.isSeparating) {
// 折叠屏半开状态:桌面、书本等模式
val halfFold = foldPosition(binding.root, foldFeature)
ConstraintLayout.getSharedValues().fireNewValue(R.id.fold, halfFold)
} else {
// 折叠屏完全展开
ConstraintLayout.getSharedValues().fireNewValue(R.id.fold, 0)
}
}
}
}
}
fun foldPosition(view: View, foldingFeature: FoldingFeature): Int {
val splitRect = getFeatureBoundsInWindow(foldingFeature, view)
splitRect?.let {
return view.height.minus(splitRect.top)
}
return 0
}
<androidx.constraintlayout.motion.widget.MotionLayout 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:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
app:layoutDescription="@xml/activity_fold_horizontal_page_scene"
tools:context=".FoldVerticalPageActivity">
<ImageView
android:id="@+id/player_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@android:color/holo_red_light"
app:layout_constraintBottom_toBottomOf="@+id/fold"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ReactiveGuide
android:id="@+id/fold"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_end="10dp"
app:reactiveGuide_animateChange="true"
app:reactiveGuide_applyToAllConstraintSets="true"
app:reactiveGuide_valueId="@id/fold" />
<ImageView
android:id="@+id/control_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@android:color/holo_blue_light"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/fold" />
androidx.constraintlayout.motion.widget.MotionLayout>
WindowInfoRepo
方式,指的是WindowManager
1.0.0-alpha07版本:将核心窗口库迁移到了 Kotlin
,依赖Kotlin Flow
。
主要原理:使用协程和挂起函数来公开异步数据,可以理解为对原有API的kotlin二次封装,更加便于使用。
private lateinit var windowInfoRepo: WindowInfoRepo
private var layoutUpdatesJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
windowInfoRepo = windowInfoRepository()
setContentView(R.layout.activity_fold_horizontal_page)
}
override fun onStart() {
super.onStart()
layoutUpdatesJob = CoroutineScope(Dispatchers.Main).launch {
windowInfoRepo.windowLayoutInfo
.collect { newLayoutInfo ->
// New posture information
...
}
}
}
override fun onStop() {
super.onStop()
layoutUpdatesJob?.cancel()
}
Google I/O 官方演示demo通过WindowManager + MotionLayout 用来适配播放器的demo。
适配原理:
MotionLayout
作根布局,增加平滑动画效果;添加辅助线ReactiveGuide
fold guideEnd位于0。约束布局由上倒下分别为:
PlayerView
ReactiveGuide
PlayerControlView
ReactiveGuide
位于屏幕最下边边缘,所以PlayerControlView
默认隐藏ReactiveGuide
上移到铰链位置PlayerView
播放画面平滑上移;PlayerControllerView
播放控制按钮平滑出现主要代码实现,参考官方博客:Tabletop mode on foldable devices
<androidx.constraintlayout.motion.widget.MotionLayout
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:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
app:layoutDescription="@xml/activity_main_scene"
tools:context=".MainActivity">
<com.google.android.exoplayer2.ui.PlayerView
android:id="@+id/player_view"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/fold"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:use_controller="false" />
<androidx.constraintlayout.widget.ReactiveGuide
android:id="@+id/fold"
app:reactiveGuide_valueId="@id/fold"
app:reactiveGuide_animateChange="true"
app:reactiveGuide_applyToAllConstraintSets="true"
android:orientation="horizontal"
app:layout_constraintGuide_end="0dp"
android:layout_height="wrap_content"
android:layout_width="wrap_content" />
<com.google.android.exoplayer2.ui.PlayerControlView
android:id="@+id/control_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/black"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/fold" />
androidx.constraintlayout.motion.widget.MotionLayout>
private fun onLayoutInfoChanged(newLayoutInfo: WindowLayoutInfo) {
if (newLayoutInfo.displayFeatures.isEmpty()) {
// The display doesn't have a display feature, we may be on a secondary,
// non foldable-screen, or on the main foldable screen but in a split-view.
centerPlayer()
} else {
newLayoutInfo.displayFeatures.filterIsInstance(FoldingFeature::class.java)
.firstOrNull { feature -> isInTabletopMode(feature) }
?.let { foldingFeature ->
val fold = foldPosition(binding.root, foldingFeature)
foldPlayer(fold)
} ?: run {
centerPlayer()
}
}
}
private fun centerPlayer() {
ConstraintLayout.getSharedValues().fireNewValue(R.id.fold, 0)
binding.playerView.useController = true // use embedded controls
}
private fun foldPlayer(fold: Int) {
ConstraintLayout.getSharedValues().fireNewValue(R.id.fold, fold)
binding.playerView.useController = false // use custom controls
}
/**
* Returns the position of the fold relative to the view
*/
fun foldPosition(view: View, foldingFeature: FoldingFeature): Int {
val splitRect = getFeatureBoundsInWindow(foldingFeature, view)
splitRect?.let {
return view.height.minus(splitRect.top)
}
return 0
}
背景:
ConstraintLayout
能让单窗格根据多种屏幕尺寸,进行自适应调整,但大屏幕设备显示时,可能会从将布局拆分为多个窗格来显示,这个时候就需要SlidingPaneLayout
来支持
自动调整两个窗格的大小,使窗格位于折叠/铰链等任一侧位置。
更多详细参考官方:创建双窗格布局,以及 SlidingPaneLayout API
垂直导航栏。功能同于底部导航,用这种导航栏实现方式的优点:大屏用户手握屏幕两边,更容易被点击,符合人体工程学的导航体验。
Material 组件增加最大宽度值,避免UI 被拉伸到整个屏幕的边缘
这里指折叠屏折叠变化时时,可通过ADB调试模拟窗口大小变化
adb shell wm size 2200x2480
adb shell wm size 1136x2480
adb shell wm size reset
新建折叠屏模拟器可选择