前天才刚写完介绍 Compose 新实验性 Style API 的文章:Compose 里的 CSS: 新 Styles API ? 🎨
今天又在 Compose 发现了一个有意思的提交:

好好好,实验性 Media Query API,逮着 CSS 使劲抄 (借鉴)是吧,抄 完声明式状态样式,再抄一个媒体查询(Media Query)。
什么是 Media Query?
在正式开始介绍这个 Media Query API 之前,我觉得还是有必要先解释一下什么是 Media Query(媒体查询)。
绝大部分人对 Media Query 的理解就是用来做响应式布局的一个 CSS 特性:

为什么叫 Media Query(媒体查询)?
在互联网刚起步的 HTML4 和 CSS2 时代(大概 90 年代末到 00 年代初),网页主要就是"带超链接的文档"。当时的工程师在考虑:这个文档最终会被输出到什么物理介质(Medium / Media)上?
是的,这里的 Media 是指物理媒介(显示文档的设备),不是你理解的音视频的那个 Media。
所以,最初的"Media"指的纯粹是输出媒介/介质。彼时的 CSS Media Types(媒体类型) 包括:
screen:电脑屏幕(最常见)print:打印机(当用户按下 Ctrl+P 打印网页时)speech:屏幕阅读器(给视障人士读出网页内容)tv:早期的电视机handheld:早期的掌上设备(PDA)
所谓的 Media Query(介质查询),最开始的意思仅仅是:"去问一下浏览器,当前渲染这个文档的物理介质是什么?" 后来随着智能手机的爆发,大家发现只查"介质类型"不够用了,屏幕有大有小啊!于是 W3C(万维网联盟)在 CSS3 中扩展了它,不仅允许查"介质"类型,还能查"介质的特征(Features)"(如宽度、高度、方向)。虽然功能进化了,但"Media Query"这个名字一直沿用了下来。
CSS 媒体查询:仅仅是做响应式布局吗?
在大家的印象里,媒体查询也许仅仅是用来写 @media (max-width: 600px) 这种适配宽度的代码。但实际上,经过多年的演进,现代 CSS 媒体查询能做的事情还是比较全面的,除了宽高和横竖屏,它还能查这些:
-
用户偏好与无障碍(Accessibility)
- 深色模式 :
@media (prefers-color-scheme: dark)。 - 减弱动画 :
@media (prefers-reduced-motion: reduce)。如果用户在系统设置里开启了"减弱动态效果"(防眩晕),网页可以据此关掉炫酷的 CSS 动画。 - 高对比度 :
@media (prefers-contrast: high)。
- 深色模式 :
-
交互与输入设备能力(Interaction & Input)
- 指针精度 :
@media (pointer: coarse): 粗糙指针,比如手指触屏;@media (pointer: fine): 高精度指针,比如鼠标;
- 悬停能力 :
@media (hover: hover)。用来判断设备是否支持鼠标悬停。如果是纯触屏手机,由于没有鼠标,不会触发悬停效果。
- 指针精度 :
-
打印排版
@media print:当用户打印网页时,可以用这个查询把网页里的导航栏、广告、侧边栏全部隐藏,把背景变成白色,只把正文打印到 A4 纸上。
Compose 的 Media Query
随着 Compose Multiplatform 向桌面端、Web 端进军,UI 框架面临的环境变得和当年的浏览器一模一样:你不知道代码最终是在带鼠标的 Mac 上跑,还是在纯触控的 Android 手机上跑。
既然如此,不妨借鉴一下 CSS 的 Media Query 吧。
废话不多说(已经说了一大堆),先来看看怎么使用吧:
目前这个 API 还在实验阶段 ,并且默认是关闭的 。要在项目中使用,你需要在应用初始化或 Compose 内容入口前手动开启集成标志,并使用
@OptIn注解:ComposeUiFlags.isMediaQueryIntegrationEnabled = true
这个新特性的核心是一个名为 UiMediaScope 的作用域接口,它就像一个"设备信息大户",里面包含了各种当前窗口和设备的属性:
kotlin
// MediaQuery.kt
interface UiMediaScope {
/** 折叠屏姿态 */
val windowPosture: Posture
/** 窗口宽度 */
@get:FrequentlyChangingValue
val windowWidth: Dp
/** 窗口高度 */
@get:FrequentlyChangingValue
val windowHeight: Dp
/** 设备输入精度 */
val pointerPrecision: PointerPrecision
/** 键盘类型 */
val keyboardKind: KeyboardKind
/** 是否支持麦克风 */
@get:Suppress("GetterSetterNames")
val hasMicrophone: Boolean
/** 是否支持相机 */
@get:Suppress("GetterSetterNames")
val hasCamera: Boolean
/** 观看距离 */
val viewingDistance: ViewingDistance
/** 姿态 */
宝宝 class Posture private constructor(private val description: String) {
override fun toString(): String = description
companion object {
// 平放/普通手机 📱
val Flat = Posture("Flat")
// 桌面半折叠模式
val Tabletop = Posture("Tabletop")
// 书本竖向折叠模式 📖
val Book = Posture("Book")
}
}
/** 输入设备精度 */
value class PointerPrecision private constructor(private val description: String) {
override fun toString(): String = description
companion object {
// 鼠标/触控笔
val Fine = PointerPrecision("Fine")
// 手指触屏
val Coarse = PointerPrecision("Coarse")
// 手柄控制器
val Blunt = PointerPrecision("Blunt")
// 没有输入设备
val None = PointerPrecision("None")
}
}
/** 键盘类型 */
value class KeyboardKind private constructor(private val description: String) {
override fun toString(): String = description
companion object {
// 实体键盘
val Physical = KeyboardKind("Physical")
// 软键盘 IME
val Virtual = KeyboardKind("Virtual")
// 没有实体键盘且 IME 处于关闭/隐藏状态
val None = KeyboardKind("None")
}
}
/** 观看距离 */
value class ViewingDistance private constructor(private val description: String) {
override fun toString(): String = description
companion object {
// 手机/平板/电脑
val Near = ViewingDistance("Near")
// 车载/底座模式
val Medium = ViewingDistance("Medium")
// 电视
val Far = ViewingDistance("Far")
}
}
}
官方提供了两个主要的 Composable 函数来读取这些信息:mediaQuery 和 derivedMediaQuery 。它们的使用场景在性能上有严格的区分。
场景一 · 读取相对稳定的状态: mediaQuery
当你要读取的状态不会非常频繁地改变 时(比如折叠屏的姿态改变、插入了鼠标、键盘弹出等),直接使用 mediaQuery 函数
kotlin
@Composable
@ReadOnlyComposable
fun mediaQuery(query: UiMediaScope.() -> Boolean): Boolean =
LocalUiMediaScope.current.query()
可以看到函数参数 query 带有 UiMediaScope 上下文,我们可以从中读取信息,返回一个 Boolean 作为查询结果。
kotlin
@Composable
fun AdaptiveButton() {
// 【1. 媒体查询读取】
// 检查当前最高精度的输入设备是不是手指触屏(Coarse)
val isTouchPrimary = mediaQuery { pointerPrecision == PointerPrecision.Coarse }
// 检查观看距离是否不是近距离(例如在电视或车载设备上)
val isUnreachable = mediaQuery { viewingDistance != ViewingDistance.Near }
// 【2. 动态响应】
// 根据输入设备和观看距离,动态计算按钮的尺寸
val adaptiveSize = when {
// 远距离设备,需要超大按钮
isUnreachable -> DpSize(150.dp, 70.dp)
// 触屏设备,需要符合手指点击的较大区域
isTouchPrimary -> DpSize(120.dp, 50.dp)
// 鼠标设备(Fine),按钮可以紧凑一些
else -> DpSize(100.dp, 40.dp)
}
Button(
modifier = Modifier.size(adaptiveSize),
onClick = { /* TODO */ }
) {
Text("Submit")
}
}
场景二 · 读取高频变化的值: derivedMediaQuery
对于 windowWidth 和 windowHeight 这种在你拖拽改变窗口大小(比如分屏)时每帧都会变化 的值,Compose 将其标记为了 @FrequentlyChangingValue 。
如果在 mediaQuery 中直接读取它们,会导致 1dp 的细微变化就引发整个 Composable 的重组,严重影响性能 。为了解决这个问题,需要使用 derivedMediaQuery,它内部包裹了 derivedStateOf ,只有当"布尔条件"发生翻转时,才会触发重组 。
kotlin
@Composable
fun ResponsiveAppLayout() {
// 【1. 高频状态监听】
// 使用 derivedMediaQuery 监听宽度。
// 假设拖拽分屏时宽度从 500dp 变到 599dp,这里不会触发重组。
// 只有当宽度突破 600dp 的临界点时,showDualPane 才会改变,并触发一次重组。
val showDualPane by derivedMediaQuery { windowWidth >= 600.dp }
Row(modifier = Modifier.fillMaxSize()) {
// 主内容区域,始终显示
MainContent(modifier = Modifier.weight(1f))
// 【2. 响应式布局切换】
// 当屏幕宽度大于等于 600dp 时,展示双窗格(比如右侧显示详情页)
if (showDualPane) {
DetailContent(modifier = Modifier.weight(1f))
}
}
}
底层实现
Compose Media Query 底层实现本质上是在 Android 原生系统服务和 Compose 响应式状态(State)之间,建立一座"桥梁"。将各种零散的 Android 系统监听器统一封装成了一个响应式的 UiMediaScope 接口 ,并通过 CompositionLocal 注入到了 Compose 树的根节点 。
核心承载体:UiMediaScopeImpl
底层实际干活的是一个叫做 UiMediaScopeImpl 的内部类 。它里面维护了一堆 Compose 的 MutableState,这意味着一旦这些值发生变化,就会自动触发用到它们的地方进行重组。
kotlin
@Stable
internal class UiMediaScopeImpl(
context: Context,
inputManager: InputManager,
windowInfo: WindowInfo,
imeVisibility: Boolean,
) : UiMediaScope {
private val packageManager = context.packageManager
var _windowInfo by mutableStateOf(windowInfo)
var _windowPosture by mutableStateOf(Posture.Flat)
var _anyPointer by mutableStateOf(resolvePointerPrecision(inputManager))
var isDocked by mutableStateOf(false)
var isImeVisible by mutableStateOf(imeVisibility)
var hasPhysicalKeyboard by mutableStateOf(hasPhysicalKeyboard(inputManager))
override val hasMicrophone: Boolean
get() = packageManager.isMicAvailable()
override val hasCamera: Boolean
get() = packageManager.isCameraAvailable()
@get:FrequentlyChangingValue
override val windowWidth: Dp
get() = _windowInfo.containerDpSize.width
@get:FrequentlyChangingValue
override val windowHeight: Dp
get() = _windowInfo.containerDpSize.height
override val windowPosture: Posture
get() = _windowPosture
override val pointerPrecision: PointerPrecision
get() = _anyPointer
override val keyboardKind: KeyboardKind
get() =
when {
hasPhysicalKeyboard -> KeyboardKind.Physical
isImeVisible -> KeyboardKind.Virtual
else -> KeyboardKind.None
}
override val viewingDistance: ViewingDistance
get() =
when {
packageManager.isTvDevice() -> ViewingDistance.Far
packageManager.isAutomotiveDevice() || isDocked -> ViewingDistance.Medium
else -> ViewingDistance.Near
}
}
数据的来源
为了获取这些硬件和环境信息,Compose 在根节点使用了一个名为 obtainUiMediaScope 的内部 Composable 函数,利用 DisposableEffect 和 LaunchedEffect 注册了各种系统级的监听器 :
kotlin
// ComposeVieContext.android.kt
@Composable
internal fun ProvideCompositionLocals(
owner: AndroidComposeView,
content: @Composable () -> Unit,
) {
...
CompositionLocalProvider(
LocalLifecycleOwner provides lifecycleOwner,
...
) {
// 如果开启了 MediaQuery 集成标志
if (isMediaQueryIntegrationEnabled) {
// 注册监听器
val mediaScope = obtainUiMediaScope(owner.context, owner.view, owner.windowInfo)
// 把 MediaScope 以 CompositionLocal 的形式提供给下层
CompositionLocalProvider(LocalUiMediaScope provides mediaScope) {
ProvideCommonCompositionLocals(
owner = owner,
uriHandler = uriHandler,
content = content,
)
}
} else {
ProvideCommonCompositionLocals(
owner = owner,
uriHandler = uriHandler,
content = content,
)
}
}
}
那 obtainUiMediaScope() 内部具体是怎么监听的呢?
kotlin
@Composable
internal fun obtainUiMediaScope(
context: Context,
view: View,
windowInfo: WindowInfo,
): UiMediaScope {
val inputManager = remember { context.getSystemService(Context.INPUT_SERVICE) as InputManager }
val initialImeVisibility = remember { ViewCompat.getRootWindowInsets(view).isImeVisible }
val scope = remember {
UiMediaScopeImpl(context, inputManager, windowInfo, initialImeVisibility)
}
scope._windowInfo = windowInfo
// Window posture
LaunchedEffect(context) {
WindowInfoTracker.getOrCreate(context).windowLayoutInfo(context).collectLatest { layout ->
scope._windowPosture = resolvePosture(layout)
}
}
// Input Devices (Pointer & Physical Keyboard)
DisposableEffect(context) {
val listener =
object : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(id: Int) = update()
override fun onInputDeviceRemoved(id: Int) = update()
override fun onInputDeviceChanged(id: Int) = update()
fun update() {
scope._anyPointer = resolvePointerPrecision(inputManager)
scope.hasPhysicalKeyboard = hasPhysicalKeyboard(inputManager)
}
}
inputManager.registerInputDeviceListener(listener, Handler(Looper.getMainLooper()))
listener.update()
onDispose { inputManager.unregisterInputDeviceListener(listener) }
}
// IME listener (Virtual Keyboard)
DisposableEffect(view) {
val listener =
ViewTreeObserver.OnGlobalLayoutListener {
scope.isImeVisible = ViewCompat.getRootWindowInsets(view).isImeVisible
}
view.viewTreeObserver.addOnGlobalLayoutListener(listener)
onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(listener) }
}
// Docked state receiver for reachability
DisposableEffect(context) {
val filter = IntentFilter(Intent.ACTION_DOCK_EVENT)
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
scope.isDocked = isDocked(intent)
}
}
val stickyIntent =
ContextCompat.registerReceiver(
context,
receiver,
filter,
ContextCompat.RECEIVER_EXPORTED,
)
scope.isDocked = isDocked(stickyIntent)
onDispose { context.unregisterReceiver(receiver) }
}
return scope
}
-
折叠屏姿态(Posture) : 接入 Jetpack WindowManager 的
WindowInfoTracker。通过收集windowLayoutInfo的流,过滤出FoldingFeature(折叠特征)。如果折叠方向是水平的,就是Tabletop(桌面半折);否则就是Book(书本模式) 。 -
输入设备与精度(Pointer & Keyboard) : 获取 Android 的
InputManager,并注册了InputDeviceListener。- 当设备连接或断开时,遍历所有输入设备的
source标志位 。 - 如果有鼠标、触控笔或触摸板(
SOURCE_MOUSE/SOURCE_STYLUS/SOURCE_TOUCHPAD),精度就是Fine。 - 如果是触摸屏(
SOURCE_TOUCHSCREEN),精度就是Coarse。 - 如果是游戏手柄(
SOURCE_JOYSTICK),则是Blunt。
- 当设备连接或断开时,遍历所有输入设备的
-
软键盘可见性(Virtual Keyboard/IME) : 为了知道软键盘有没有弹出来,给根 View 注册了
ViewTreeObserver.OnGlobalLayoutListener,然后通过ViewCompat.getRootWindowInsets(view).isVisible(WindowInsetsCompat.Type.ime())来实时判断 。 -
观看距离(Viewing Distance) : 用一个
BroadcastReceiver去监听系统的Intent.ACTION_DOCK_EVENT(底座模式事件) 。同时结合PackageManager判断是不是电视(FEATURE_LEANBACK)或车机(FEATURE_AUTOMOTIVE)。如果是电视就是Far,车机或插在底座上就是Medium,默认手机是Near。
LocalUiMediaScope
在前面我们也看到 UiMediaScope 是通过 CompositionLocal 传递的:
kotlin
val LocalUiMediaScope =
staticCompositionLocalOf<UiMediaScope> {
error("CompositionLocal LocalUiMediaScope not present")
}
所以除了使用 mediaQuery 和 derivedMediaQuery 来生成 Boolean 状态值,我们还可以直接在 Composable 读取 LocalUiMediaScope.current 来获取各种具体属性。
性能优化:derivedMediaQuery
kotlin
@Composable
fun derivedMediaQuery(query: UiMediaScope.() -> Boolean): State<Boolean> {
// 通过 CompositionLocal 获取当前的 UiMediaScope
val mediaScope = LocalUiMediaScope.current
// 记住最新的查询条件(避免 query lambda 变化导致逻辑错误)
val currentQuery by rememberUpdatedState(query)
// 使用 derivedStateOf 包裹查询执行过程
// 只有当 currentQuery() 的【布尔计算结果】发生变化时(比如从 false 变成了 true),
// 才会通知下游进行重组,屏蔽了高频的无效刷新。
return remember(mediaScope) {
derivedStateOf { mediaScope.currentQuery() }
}
}
值得一提的是,
derivedMediaQuery这个名字和derivedStateOf有点像, 一般使用derivedStateOf的时候是要把它包在remember { ... }里的,而derivedMediaQuery是不需要remember { ... }包裹的,因为它的内部已经使用了remember {}。Compose 团队也说后面可能会考虑将其重命名为
rememberDerivedMediaQuery:具体 commit 详见:Introduce experimental Media Query APIs for adaptive layouts
和 Style API 一起荡起双桨
最后想说的是,Compose Media Query API 和 Style API 配合食用更佳哦,如果还不了解 Style API 的可以看我的上一篇文章:Compose 里的 CSS: 新 Styles API ? 🎨
kotlin
@Composable
fun AdaptiveStylesSample() {
@Composable
fun ClickableStyleableBox(
onClick: () -> Unit,
modifier: Modifier = Modifier,
style: Style = Style,
) {
val interactionSource = remember { MutableInteractionSource() }
val styleState = remember { MutableStyleState(interactionSource) }
Box(
modifier =
modifier
.clickable(interactionSource = interactionSource, onClick = onClick)
.styleable(styleState, style)
)
}
ClickableStyleableBox(
onClick = {},
style = {
background(Color.Green)
// 根据窗口大小动态改变尺寸
if (mediaQuery { windowWidth > 600.dp && windowHeight > 400.dp }) {
size(200.dp)
} else {
size(150.dp)
}
// Hover state for fine pointer input
if (mediaQuery { pointerPrecision == PointerPrecision.Fine }) {
hovered { background(Color.Yellow) }
}
pressed { background(Color.Red) }
},
)
}
具体 commit 详见: