Jetpack Compose 8 月版正式发布,核心模块 1.12

1 阅读8分钟

这次的版本没有什么特别大的架构变动,基本属于那种 “Compose 开始补齐 UI Framework 能力” 的版本,比如:图形管线、富文本编辑、程序化文本选择、手势驱动动画、Grid、Credential Manager、启动性能等等。

Mesh Gradient 和完整 WCG/HDR 管线

以前 Compose 自带的 Gradient 基本还是 Linear、Radial、Sweep 这类常见的“一条轴或者一个中心点”的渐变,而新增的 Mesh Gradient 现在可以定义一个二维网格,在不同顶点指定位置和颜色,然后 GPU 在这些控制点之间进行插值,也就是可以做出现在设计稿里很常见的那种多色、有机、流体感背景。

对应的 API 大致为:

val gradientPainter = remember {
    MeshGradientPainter(1, 1) {
        setVertex(0, 0, Offset(0f, 0f), Color.Red)
        setVertex(0, 1, Offset(1f, 0f), Color.Blue)
        setVertex(1, 0, Offset(0f, 1f), Color.Green)
        setVertex(1, 1, Offset(1f, 1f), Color.Yellow)
    }
}
​
Box(
    Modifier.paint(gradientPainter)
)

这里比较有意思的是它在 alpha 阶段最初叫 Modifier.meshGradient(),最后稳定下来的时候, Google 把 API 改成了 MeshGradientPainter + Modifier.paint()

这部分 API 底层用硬件加速的 Canvas.drawMesh,也就是 Google 最后还是把 Mesh Gradient 定位成一种 Painter/绘制能力,没有把它当成 Modifier 自己的一套特殊渲染机制。

实际上这对于以后动画化 Mesh Gradient 也更合理:你可以修改网格顶点、颜色,然后正常进入 Compose 绘制体系。

更重要的其实是 Wide Color Gamut + HDR ,Compose 以前虽然 Color 本身可以支持 Display P3、Adobe RGB 等 ColorSpace,但进入 PaintShader、Android Canvas 的过程,部分路径会被转换/压缩回 sRGB,现在 1.12 把这条链真正补通:

Compose Color → Paint / Shader → Canvas → Android platform rendering

也就是 API 29+ 可以保留 Display P3、Adobe RGB 等非 sRGB 色彩,不会再在 Compose 中间环节被 clamp,平台不支持的 ColorSpace、系统版本过低这些情况,才会安全回退到 sRGB。

BasicTextField

虽然之前 Compose 显示 AnnotatedString,也能通过 OutputTransformation 给 TextField 显示结果加样式,但那种方案其实类似于数据还是普通文本,只是在绘制输出的时候给某些字符穿一件不同的衣服。

比如电话号码 12345678900 状态里还是这一串字符,屏幕上可以显示成 123 4567 8900 ,也可以显示时加上一些颜色,然后 Compose 1.12 新增了另一层能力:

state.edit {
    addStyle(
        SpanStyle(
            fontWeight = FontWeight.Bold,
            color = Color.Blue
        ),
        start = 0,
        end = 9
    )
}

也就是现在 TextFieldBuffer 本身可以 addStyle()getSpanStyles()getParagraphStyles(),同时 TextFieldState 也有 textStyles ,样式可以跟着编辑状态一起存在,可以查询、修改、删除,并且能够跨 configuration change 保存。

也就是 Compose 自己的 TextField 架构更接近一套 Markdown Editor、富文本笔记、之类的见即所得编辑器,比如以前你写这些东西经常会陷入:

原始字符串
      ↓
AnnotatedString
      ↓
selection offset 映射
      ↓
IME offset
      ↓
Transformation offset
      ↓
Undo/Redo

然后稍微改一下文本,所有 span range 都得自己维护,现在 Style 本身进入 TextField Editing Model 里面, 等于是状态模型层面的增强,整套可用性会提高很多:

val state = rememberTextFieldState("Formatted text in Compose 1.12")
​
// Apply bold and color styles to a range of text
state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold, color = Color.Blue),
        start = 0,
        end = 9
    )
}
​
// Query active styles from TextFieldState
val currentStyles = state.textStyles

SelectionState

从 1.12 开始,SelectionState 支持把“选中文字”变成可以控制的 State ,比如:

val selectionState = rememberSelectionState()
​
SelectionContainer(
    state = selectionState
) {
    Text(...)
}

之后程序可以直接通过这些 API 进行操作:

selectionState.selectAll()
selectionState.clear()
selectionState.select(...)
selectionState.extendSelectionByWord()

还可以观察:

selectionState.selectedTexts

甚至通过 getSelectableTexts() 获得 SelectionContainer 内所有可以选择的文本,再按照全局 range 跨多个 Composable 选中内容

也就是以前 SelectionContainer 基本是:“我允许用户长按,然后 Compose 帮我显示 selection handles。”

现在 selection 开始成为可以操作的数据,例如一个 AI Chat:

回答第一段
回答第二段
代码块
回答第三段

程序可以直接实现:

“全选回答”、“选中某一段”、“从当前位置扩展一个单词”、“获取当前选中的所有 AnnotatedString”。

@Composable
fun ProgrammaticSelectionExample() {
    val selectionState = rememberSelectionState()
​
    Column {
        Button(
            onClick = { selectionState.selectAll() },
            modifier = Modifier.disableSelectionClearOnTap()
        ) {
            Text("Select All")
        }
​
        SelectionContainer(state = selectionState) {
            Text("Text content to be selected programmatically.")
        }
    }
}

另外 1.12 还支持拖动 selection 超过 viewport 时自动滚动,这类细节对长文阅读器、编辑器就很实用

Deferred Animation

这个感觉是这次最有意思的一个更新,DeferredTargetAnimation 正式脱离 Experimental,同时增加:

DeferredAnimatedContent
DeferredAnimatedVisibility

核心场景就是 Predictive Back,假设用户从屏幕左边往右滑,动画前半段可以通过手指控制:

手指 20%
页面 scale / offset 20%
​
手指 50%
页面 scale / offset 50%

但当用户松手以后,动画就需要:

当前手势位置 + 当前速度
            ↓
Compose Transition 接管
            ↓
继续动画到最终状态

所以 Compose 1.12 的 Deferred Transition 就是专门把这两阶段连接起来:

Gesture controlled phase
          ↓
     defer()
          ↓
手动修改 scale / offset
          ↓
用户松手
          ↓
自动 Transition 接管
          ↓
速度继续传递
          ↓
最终 Target State

这里 Google 甚至特别强调了 velocity transfer自动动画接手时会把之前用户手势的速度继承过去,不然就很容易出现一种 UI 感:

用户快速一甩
      ↓
松手
      ↓
动画突然像重新启动一样慢下来

也就是现在动画能够延续用户松手瞬间的运动趋势,然后 permitTransformDuringDeferredTransition 又可以进一步处理 Shared Element,让 shared element 在 deferred 阶段跟随 parent 一起变换,所以这一整套其实是在建立统一动画基础设施,比如:

Predictive Back、drag-to-dismiss、interactive navigation、gesture-driven transition

Grid Named Areas

类似把 CSS Grid 的思路搬进 Compose,1.12 的 Experimental Grid 新增 Named Areas,比如:

@OptIn(ExperimentalGridApi::class)
@Composable
fun DashboardLayout() {
    Grid(
        config = {
            area("header", row = 0, column = 0, rowSpan = 1, columnSpan = 2)
            area("sidebar", row = 1, column = 0)
            area("content", row = 1, column = 1)
            gap(16.dp)
        }
    ) {
        HeaderSection(modifier = Modifier.gridItem(areaId = "header"))
        NavigationSidebar(modifier = Modifier.gridItem(areaId = "sidebar"))
        MainContentView(modifier = Modifier.gridItem(areaId = "content"))
    }
}

甚至还有更骚的,不过不是这部分内容而已:

以前这种 Dashboard,一般要给每个元素写 row = 1column = 0span = 2

+----------------------+
| Header               |
+--------+-------------+
| Sidebar| Content     |
|        |             |
+--------+-------------+

现在可以先声明:

area("header", ...)
area("sidebar", ...)
area("content", ...)

组件只需要 Modifier.gridItem(areaId = "header"),做到 Layout Structure 与 Component 分离 ,比如以后 Compact:

header
content
sidebar

Expanded:

header header
sidebar content

你只需要改变 Grid Configuration,里面的组件依旧只知道:

我是 sidebar
我是 content

这明显是给 Android 现在大力推的 Phone + Foldable + Tablet + Desktop Window + XR 实现自适应布局服务的。

性能

首先是一个新支持优化,以前如果想表达 userId 或 screenName 改变时执行一次副作用,一般需要写:

LaunchedEffect(userId, screenName) { ... }

但如果里面根本没有 coroutine 工作,这实际上为了一个很轻的 side effect 做了比较重的协程相关操作,然后1.12 给 SideEffect 增加 key:

SideEffect(userId, screenName) {
    analytics.log(...)
}

Google 给出的 microbenchmark 是:

API新 Keyed SideEffect
对比 LaunchedEffect最多快约 90%
对比 DisposableEffect20%

也就是在特定 Effect 调度场景里的新 API 可以做到更好的性能而且执行顺序也不同:

SideEffect 会比 DisposableEffectLaunchedEffect 更早执行,所以不能看见 90% 就机械替换,,Google 也专门提醒了这一点。

另外还有就是 Startup Performance,Google 提到了:

Compose 1.12 的 TTID 已经在他们的 benchmark 中做到与 Views comparable。

也就是冷启动到第一帧出现 的时间,终于基本追上传统 View,而在今年 5 月 Google 公布的 Pokedex Hero Benchmark 里,Compose 1.11 里,TTID 比 Views 慢 2.5%,TTFD 比 Views 慢 13.0%,所以这个提升还是很明显的。

另外滚动性能从 Compose 1.9 开始已经达到同样的 0.21% jank rate

其他

现在 Compose TextField 现在可以通过新的 credentialRequest semantics 接 Android Credential Manager,在 API 34+ 直接通过 Autofill Framework 集成,低版本继续通过 androidx.credentials 处理,所以 Passkey、保存的账号密码、Sign-in credential 能更自然地进入 Compose TextField 输入流程。

KeyboardType 也增加了:

Date
Time
DateTime
SignedDecimal

另外 downloadable font 也开始支持 variable font variation settings。

然后 Compose 组件还增加了自动 click / focus navigation sound,同时提供 SoundEffectOnInteraction 关闭。

Graphics 还有个挺实用的新 API:LayerOutsets ,可以扩大 GraphicsLayer 的 visual bounds,解决 layer 被提升为 offscreen buffer 后,内容容易因为实际 measured bounds 而被裁掉的问题,这个对于 blur、shadow、复杂 graphics layer 会比较有用。

另外还有个坑,但Compose 1.12 的 compileSdk 已更新到 API 37 / Android 17,明确要求最低 AGP 9.2.0,也就是高版本 AGP 跑不掉了,如果你想升级:

所以,准备好了吗?吃上 AGP 9.2 的 Compose 1.12 升级。