前天才刚写完介绍 Compose 新实验性 Style API 的文章:Compose 里的 CSS: 新 Styles API ? 🎨
今天又在 Compose 发现了一个有意思的提交:Introduce experimental Media Query APIs for adaptive layouts
好好好,实验性 Media Query API,逮着 CSS 使劲抄(借鉴)是吧,抄完声明式状态样式,再抄一个媒体查询(Media Query)。
什么是 Media Query?
在正式开始介绍这个 Media Query API 之前,我觉得还是有必要先解释一下什么是 Media Query(媒体查询)。
绝大部分人对 Media Query 的理解就是用来做响应式布局的一个 CSS 特性:
为什么叫 Media Query(媒体查询)?
对绝大多数人来说,一听到“Media”(媒体),第一反应绝对是“多媒体”(音频、视频、图片)或者“新闻媒体”等内容。但在这里,"Media" 其实是英文单词 "Medium"(媒介/介质)的复数形式。
在计算机科学和 UI 渲染领域,媒介(Medium) 到底是什么意思呢?你可以通俗地理解:媒介,就是“承载和向人类传递信息的物理载体”。 你的代码和 UI 布局只是一堆虚拟的数字和逻辑,它们必须通过某种“物理载体”才能被人类感知到。这个“载体”就是媒介。
在互联网刚起步的 HTML4 和 CSS2 时代(大概 90 年代末到 00 年代初),网页主要就是“带超链接的文档”。当时的工程师在考虑:这个文档最终会被输出到什么物理介质(Medium / Media)上?
早期的输出媒介/介质包括:
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 纸上。
如今,当我们讨论 Media Query 时,代码真正在问的是:“嘿,系统!你现在正用什么『物理载体(媒介)』来展示我的界面?这个载体又有哪些具体的物理特征?”
系统可能会回答:“我正在用一块屏幕(媒介)展示你的 App,这块屏幕的宽度是 600dp,用户正在用手指(粗糙的输入方式)戳它,并且设备目前折叠成了书本的形状(折叠屏)。”
开发者查到了这些“物理载体”的特征,就可以针对性地改变 UI 的长相:如果屏幕宽就左右分屏;如果用户用手指戳,就把按钮做大一点;如果是折叠屏,就避开中间的铰链。
Compose 的 Media Query
随着 Compose Multiplatform 向桌面端、Web 端进军,UI 框架面临的环境变得和当年的浏览器一模一样:你不知道代码最终是在带鼠标的 Mac 上跑,还是在纯触控的 Android 手机上跑。
既然如此,不妨借鉴一下 CSS 的 Media Query 吧。
废话不多说(已经说了一大堆),先来看看怎么使用吧:
目前这个 API 还在实验阶段 ,并且默认是关闭的 。要在项目中使用,你需要在应用初始化或 Compose 内容入口前手动开启集成标志,并使用
@OptIn注解:ComposeUiFlags.isMediaQueryIntegrationEnabled = true
这个新特性的核心是一个名为 UiMediaScope 的作用域接口,它就像一个“设备信息大户”,里面包含了各种当前窗口和设备的属性:
// 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 函数
@Composable
@ReadOnlyComposable
fun mediaQuery(query: UiMediaScope.() -> Boolean): Boolean =
LocalUiMediaScope.current.query()
可以看到函数参数 query 带有 UiMediaScope 上下文,我们可以从中读取信息,返回一个 Boolean 作为查询结果。
@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 ,只有当“布尔条件”发生翻转时,才会触发重组 。
@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,这意味着一旦这些值发生变化,就会自动触发用到它们的地方进行重组。
@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 注册了各种系统级的监听器 :
// 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() 内部具体是怎么监听的呢?
@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 传递的:
val LocalUiMediaScope =
staticCompositionLocalOf<UiMediaScope> {
error("CompositionLocal LocalUiMediaScope not present")
}
所以除了使用 mediaQuery 和 derivedMediaQuery 来生成 Boolean 状态值,我们还可以直接在 Composable 读取 LocalUiMediaScope.current 来获取各种具体属性。
性能优化:derivedMediaQuery
@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 ? 🎨
@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) }
},
)
}