29、WindowManager 在 Jetpack Compose 中的适配:Compose 如何与 Window 交互?

说实话,Compose 出来那会儿,我第一反应是:这玩意儿怎么跟 WindowManager 打交道?

毕竟我们做 Android 开发的都知道,WindowManager 是系统级的窗口管理服务。而 Compose 呢,它是个声明式 UI 框架,跑在 View 系统之上。这两者之间,到底是怎么个配合法?

今天我们就来聊聊这个。我会结合我自己的踩坑经历,把 Compose 和 Window 的交互机制讲清楚。

Compose 的窗口本质:它还是个 View

很多人以为 Compose 完全抛弃了 View 系统。其实不是的。

你想想看,Compose 最终还是要渲染到屏幕上。而屏幕上的内容,在 Android 里必须通过 Window 来承载。所以 Compose 再怎么声明式,底层还是得跟 WindowManager 打交道。

具体来说,Compose 的 setContent { } 背后,实际上创建了一个 ComposeView。这个 ComposeView 继承自 ViewGroup,它就是一个普通的 View。然后通过 ActivityDialogsetContentView() 方法,把这个 View 挂到 Window 上。

核心结论: Compose 并没有绕过 WindowManager。它只是把 View 层的内容替换成了 Compose 的渲染逻辑。Window 的创建、管理、属性设置,依然走的是 WindowManager 那套流程。

Compose 中获取 Window 的几种方式

我在项目中经常需要获取 Window 对象,比如设置屏幕亮度、调整窗口 flag。在 Compose 里怎么做?

方式一:通过 LocalContext

最直接的方式,就是通过 LocalContext.current 拿到 Activity,然后获取 Window。

@Composable
fun MyScreen() {
    val context = LocalContext.current
    val window = remember {
        (context as? Activity)?.window
    }
    
    // 使用 window 设置 flag
    LaunchedEffect(Unit) {
        window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
    }
}

嗯,这里要注意:LocalContext 拿到的可能是 Application 上下文,不一定能强转为 Activity。我建议加个安全判断,或者用 LocalActivity 这个 CompositionLocal。

方式二:使用 LocalWindowInfo

Compose 1.2 之后,官方提供了 LocalWindowInfo。这个可以拿到窗口的尺寸、安全区域等信息。

@Composable
fun WindowInfoExample() {
    val windowInfo = LocalWindowInfo.current
    val density = LocalDensity.current
    
    // 获取窗口尺寸(单位:dp)
    val windowSize = with(density) {
        windowInfo.containerSize.toDpSize()
    }
    
    Text("窗口尺寸:${windowSize.width} x ${windowSize.height}")
}

我个人习惯用这个方式,因为它不依赖 Activity,在 Dialog 或 Popup 里也能正常工作。

Compose 中的 Dialog 与 Window

说到 Dialog,这里有个坑。我记得有一次,我在 Compose 里用 Dialog 组件弹出一个全屏对话框,结果发现状态栏被盖住了。

为什么会这样?因为 Compose 的 Dialog 本质上还是通过 DialogWindow 创建的,它默认的 Window 类型是 TYPE_APPLICATION。如果你想要全屏效果,得手动设置 Window 参数。

@Composable
fun FullScreenDialog(
    onDismiss: () -> Unit
) {
    val context = LocalContext.current
    
    Dialog(
        onDismissRequest = onDismiss,
        properties = DialogProperties(
            usePlatformDefaultWidth = false // 允许全屏
        )
    ) {
        // 这里需要手动设置 Window 参数
        val dialog = LocalDialog.current
        LaunchedEffect(Unit) {
            dialog?.window?.apply {
                setFlags(
                    WindowManager.LayoutParams.FLAG_FULLSCREEN,
                    WindowManager.LayoutParams.FLAG_FULLSCREEN
                )
                // 设置布局参数
                setLayout(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT
                )
            }
        }
        
        Box(
            modifier = Modifier.fillMaxSize().background(Color.Black)
        ) {
            // 你的全屏内容
        }
    }
}

注意: LocalDialog 是 Compose 内部提供的 CompositionLocal,需要在 Dialog 的 content 内部使用。如果你在 Dialog 外部调用,会拿到 null。

Compose 与 WindowInsets:安全区域的适配

这个我必须要讲。很多 Compose 新手都会遇到一个问题:内容被状态栏或导航栏遮挡。

在传统 View 系统里,我们通过 fitsSystemWindows 来处理。在 Compose 里,用的是 WindowInsets 系统。

@Composable
fun SafeAreaScreen() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .windowInsetsPadding(
                WindowInsets.systemBars // 自动避开状态栏和导航栏
            )
    ) {
        // 你的内容
    }
}

但这里有个细节:windowInsetsPadding 只对当前组件生效。如果你有嵌套滚动,或者需要处理 IME 键盘弹出,情况会更复杂。

我曾经在做一个聊天界面时,键盘弹起后输入框被挡住了。后来发现是 imePadding() 没加对位置。正确的做法是:

@Composable
fun ChatScreen() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .imePadding() // 给输入框留出键盘空间
            .windowInsetsPadding(WindowInsets.systemBars)
    ) {
        // 消息列表
        LazyColumn(
            modifier = Modifier.weight(1f)
        ) { /* ... */ }
        
        // 输入框
        TextField(
            value = "",
            onValueChange = { },
            modifier = Modifier.fillMaxWidth()
        )
    }
}

Compose 中的 Popup 与 WindowManager

Compose 的 Popup 组件,底层也是通过 WindowManager 添加一个悬浮窗口。不过它做了很多封装,比如对齐、偏移等。

但如果你需要更精细的控制,比如自定义弹出位置、动画,那还是得直接操作 WindowManager。

@Composable
fun CustomPopup(
    anchorBounds: Rect,
    content: @Composable () -> Unit
) {
    val context = LocalContext.current
    val windowManager = remember {
        context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
    }
    
    // 这里需要创建一个 ComposeView 作为弹出内容
    // 然后通过 WindowManager.addView() 添加到窗口
    // 具体代码略长,核心思路就是:
    // 1. 创建 ComposeView
    // 2. 设置 LayoutParams(类型、位置、宽高)
    // 3. 调用 windowManager.addView()
    // 4. 在 dispose 时调用 windowManager.removeView()
}

小技巧: 如果你只是需要简单的弹出菜单,直接用 Compose 的 DropdownMenu 就好。它内部已经处理好了 WindowManager 的交互。只有当你需要完全自定义的弹出窗口时,才考虑手动操作。

知识体系总览

下面这张图,是我梳理的 Compose 与 Window 交互的核心脉络:

Compose 与 Window 交互架构 Compose UI ComposeView (ViewGroup) WindowManager Dialog (DialogWindow) Popup (悬浮窗口) WindowInsets 核心:Compose 通过 ComposeView 桥接 View 系统,最终由 WindowManager 管理窗口生命周期 Dialog、Popup、WindowInsets 是三种常见的交互场景

避坑指南:我踩过的几个坑

最后,分享几个我实际项目中遇到的坑,希望能帮你少走弯路。

  • 坑一:Dialog 的 Window 类型 - 我曾经在 Compose Dialog 里弹出另一个 Dialog,结果第二个 Dialog 无法获取焦点。原因是 Dialog 默认的 Window 类型是 TYPE_APPLICATION,嵌套使用时需要调整 flag。
  • 坑二:WindowInsets 的时机 - 在 LaunchedEffect 里读取 WindowInsets 可能拿到 0。因为 Compose 的布局还没完成。我建议用 BoxWithConstraints 或者 onSizeChanged 来获取真实尺寸。
  • 坑三:Popup 的触摸事件 - Compose 的 Popup 默认会拦截外部触摸事件。如果你想让点击外部区域关闭 Popup,需要设置 popupPositionProvideronDismissRequest。这个我折腾了半天才搞清楚。

嗯,关于 Compose 与 Window 的交互,核心就是理解 ComposeView 这个桥梁。它把声明式的 Compose 代码,翻译成了 WindowManager 能理解的 View 操作。只要抓住这个本质,很多问题就迎刃而解了。

一句话总结: Compose 没有重新发明窗口管理。它只是换了一种更优雅的方式,来操作 WindowManager 这个老朋友。


公众号:蓝海资料掘金营,微信deep3321