Compose 里的媒体查询:Media Query API ? 🖱️👇🕹️

71 阅读11分钟

Banner.png

前天才刚写完介绍 Compose 新实验性 Style API 的文章:Compose 里的 CSS: 新 Styles API ? 🎨

今天又在 Compose 发现了一个有意思的提交:Introduce experimental Media Query APIs for adaptive layouts

Introduce_experimental_Media_Query_APIs_for_adaptive_layouts.png

好好好,实验性 Media Query API,逮着 CSS 使劲(借鉴)是吧,完声明式状态样式,再一个媒体查询(Media Query)。

什么是 Media Query?

在正式开始介绍这个 Media Query API 之前,我觉得还是有必要先解释一下什么是 Media Query(媒体查询)。

绝大部分人对 Media Query 的理解就是用来做响应式布局的一个 CSS 特性:

Media Query in CSS.png

为什么叫 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 函数来读取这些信息:mediaQueryderivedMediaQuery 。它们的使用场景在性能上有严格的区分。

场景一 · 读取相对稳定的状态: 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

对于 windowWidthwindowHeight 这种在你拖拽改变窗口大小(比如分屏)时每帧都会变化的值,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 函数,利用 DisposableEffectLaunchedEffect 注册了各种系统级的监听器 :

// 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")  
    }

所以除了使用 mediaQueryderivedMediaQuery 来生成 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: rememberDerivedMediaQuery.png 具体 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) }
    },
  )
}