Administrator
发布于 2026-09-22 / 5,067 阅读
0
0

Android 多窗口与桌面模式:从 Split-Screen 到 Desktop 工程实战

前言

折叠屏、平板、桌面模式——Android 的形态越来越多。用户的 App 不再只在一个全屏窗口里跑,而是可能在分屏的半屏里、浮窗里、甚至 Samsung DeX 的独立窗口里。

如果你只测过全屏手机,上线后用户报告"分屏后布局乱了""切窗口白屏""键盘弹上来盖住输入框",这些坑都得提前处理。

一、多窗口模式分类

Android 支持三种多窗口形态:

模式

说明

触发方式

分屏 (Split-Screen)

两个 App 各占半屏

用户从 Recents 拖拽

自由窗口 (Free-Form)

任意大小窗口

桌面模式 / 厂商扩展

画中画 (PiP)

小窗浮在上层

进入 PiP 模式

+----------+----------+      +----------+
|          |          |      |  窗口 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
        }
    }
}

八、测试矩阵

场景

测试要点

手机分屏

布局是否自适应、键盘是否覆盖输入框

平板横屏

是否走双栏布局、列表选择状态

折叠屏展开/折叠

切换是否重启 Activity、布局是否响应

PiP 进入/退出

视频是否继续播放、返回是否恢复

桌面窗口

右键菜单、键盘快捷键、拖拽

窗口拖拽缩放

最小尺寸是否合理、内容是否被截断

# 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、折叠屏三个场景。这三个场景有问题,用户会直接差评。


评论