前言
折叠屏、平板、桌面模式——Android 的形态越来越多。用户的 App 不再只在一个全屏窗口里跑,而是可能在分屏的半屏里、浮窗里、甚至 Samsung DeX 的独立窗口里。
如果你只测过全屏手机,上线后用户报告"分屏后布局乱了""切窗口白屏""键盘弹上来盖住输入框",这些坑都得提前处理。
一、多窗口模式分类
Android 支持三种多窗口形态:
+----------+----------+ +----------+
| | | | 窗口 A |
| App A | App B | | +------+ |
| | | | |窗口B | |
| | | | +------+ |
+----------+----------+ | |
+----------+
分屏 自由窗口
二、检测多窗口状态
class MultiWindowAwareActivity : ComponentActivity() {
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
checkMultiWindow()
}
// 方式一:检查是否在多窗口模式
private fun checkMultiWindow() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (isInMultiWindowMode) {
// 当前在分屏或自由窗口
onEnterMultiWindow()
} else {
onExitMultiWindow()
}
}
}
// 方式二:监听 PictureInPicture 模式
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration
) {
if (isInPictureInPictureMode) {
// 进入画中画
onEnterPiP()
} else {
onExitPiP()
}
}
// 方式三:监听窗口尺寸变化
override fun onWindowFocusChanged(hasFocus: Boolean) {
// 窗口获得/失去焦点
}
}
三、Manifest 声明
<activity
android:name=".MainActivity"
android:resizeableActivity="true"
android:supportsPictureInPicture="true"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:allowEmbedded="true">
<layout
android:defaultWidth="600dp"
android:defaultHeight="500dp"
android:gravity="top|start"
android:minWidth="240dp"
android:minHeight="180dp" />
</activity>
resizeableActivity="true":允许窗口被拖拽调整大小supportsPictureInPicture="true":支持画中画configChanges:声明后窗口变化不重启 Activity<layout>:定义自由窗口的默认大小
四、响应式布局
多窗口最大的挑战:你的 App 可能在 320dp 宽(分屏半屏)到 1200dp 宽(全屏平板)之间变化。
Compose 响应式
@Composable
fun AdaptiveLayout() {
// 计算 windowSizeClass
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
when (windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.Compact -> {
// < 600dp:分屏窄屏 / 手机竖屏
CompactLayout()
}
WindowWidthSizeClass.Medium -> {
// 600-840dp:分屏宽屏 / 小平板
MediumLayout()
}
WindowWidthSizeClass.Expanded -> {
// > 840dp:全屏平板 / 桌面窗口
ExpandedLayout()
}
}
}
@Composable
fun CompactLayout() {
// 单栏,底部导航
Scaffold(
bottomBar = { BottomNavigation() }
) { padding ->
NavHost(
modifier = Modifier.padding(padding),
navController = navController,
startDestination = Home
) {
composable<Home> { HomeListScreen() }
}
}
}
@Composable
fun ExpandedLayout() {
// 双栏,侧边导航
Row {
NavigationRail() // 侧边导航替代底部导航
NavHost(navController, startDestination = Home) {
composable<Home> {
// 列表 + 详情并列
Row {
HomeListScreen(modifier = Modifier.weight(1f))
HomeDetailScreen(modifier = Modifier.weight(1.5f))
}
}
}
}
}
五、画中画(PiP)实战
class VideoPlayerActivity : ComponentActivity() {
private var isPlaying = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
binding.playButton.setOnClickListener {
isPlaying = !isPlaying
updatePlayButton()
}
}
// 进入 PiP
fun enterPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val params = PictureInPictureParams.Builder()
.setAspectRatio(16f / 9f) // 宽高比
.setSourceRectHint(
// 告诉系统从哪个区域动画过渡
sourceRect
)
.setActions(
// PiP 窗口上的按钮
listOf(
createPlayPauseAction(),
createCloseAction()
)
)
.setAutoEnterEnabled(true) // Android 12+:返回自动进 PiP
.build()
enterPictureInPictureMode(params)
}
}
private fun createPlayPauseAction(): RemoteAction {
val intent = Intent("com.example.action.PLAY_PAUSE")
val pendingIntent = PendingIntent.getBroadcast(
this, 0, intent,
PendingIntent.FLAG_IMMUTABLE
)
return RemoteAction(
Icon.createWithResource(this,
if (isPlaying) android.R.drawable.ic_media_pause
else android.R.drawable.ic_media_play
),
if (isPlaying) "暂停" else "播放",
"播放/暂停",
pendingIntent
)
}
// 监听 PiP Action
private val pipActionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
"com.example.action.PLAY_PAUSE" -> {
isPlaying = !isPlaying
updatePlayButton()
// 更新 PiP 窗口的 Action 图标
updatePipActions()
}
}
}
}
// 更新 PiP Actions
private fun updatePipActions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val params = PictureInPictureParams.Builder()
.setActions(listOf(createPlayPauseAction(), createCloseAction()))
.build()
setPictureInPictureParams(params)
}
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// 用户按 Home 键时自动进入 PiP(Android 12+ 用 setAutoEnterEnabled)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S && isPlaying) {
enterPictureInPicture()
}
}
}
六、多窗口下的生命周期
全屏 -> 分屏:
onPause -> onMultiWindowChanged(true) -> onPictureInPictureModeChanged(false) -> onConfigurationChanged
(不调 onDestroy,Activity 不重启)
分屏 -> 全屏:
onMultiWindowChanged(false) -> onConfigurationChanged -> onResume
全屏 -> PiP:
onPause -> onPictureInPictureModeChanged(true) -> onConfigurationChanged
(Activity 不重启)
窗口大小变化:
onConfigurationChanged (如果声明了 configChanges)
否则: onDestroy -> onCreate (重启 Activity)
关键:如果没在 Manifest 声明 configChanges="screenSize|smallestScreenSize|screenLayout|orientation",每次窗口大小变化都会重启 Activity,导致数据丢失。
七、桌面模式适配
Samsung DeX、ChromeOS、Motorola Ready For 把 Android App 跑在桌面环境。适配要点:
class DesktopAwareActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 检测是否在桌面模式
if (isDesktopMode()) {
// 1. 用鼠标右键弹出 ContextMenu
// 2. 支持键盘快捷键
// 3. 窗口可拖拽调整大小
setupDesktopFeatures()
}
}
private fun isDesktopMode(): Boolean {
val config = resources.configuration
// 桌面模式通常有键盘、非触控导航
return config.keyboard == Configuration.KEYBOARD_QWERTY &&
config.navigation == Configuration.NAVIGATION_DPAD
}
private fun setupDesktopFeatures() {
// 右键菜单
binding.listView.setOnCreateContextMenuListener { menu, v, menuInfo ->
menu.add(0, MENU_COPY, 0, "复制")
menu.add(0, MENU_DELETE, 1, "删除")
}
// 键盘快捷键
binding.root.setOnKeyListener { _, keyCode, event ->
if (event.isCtrlPressed) {
when (keyCode) {
KeyEvent.KEYCODE_C -> { copySelected(); true }
KeyEvent.KEYCODE_V -> { paste(); true }
KeyEvent.KEYCODE_S -> { save(); true }
else -> false
}
} else false
}
}
}
八、测试矩阵
# adb 模拟分屏
adb shell am start -n com.example/.MainActivity --multi-window
# adb 模拟桌面模式
adb shell settings put global desktop_mode 1
# adb 模拟 PiP
adb shell am start -n com.example/.VideoPlayerActivity --user 0
# 然后在 App 里触发 enterPictureInPicture
小结
多窗口适配的核心是"布局响应式 + 生命周期管理"。声明 configChanges 避免 Activity 重启,用 WindowSizeClass 做布局自适应,PiP 用 setAutoEnterEnabled 简化逻辑。
测试时一定覆盖分屏、PiP、折叠屏三个场景。这三个场景有问题,用户会直接差评。