在 Jetpack Compose 中为元素添加动画

1. 简介

Jetpack Compose Logo

上次更新时间 2023-11-21

在本 Codelab 中,您将学习如何使用 Jetpack Compose 中的一些动画 API。

Jetpack Compose 是一种现代 UI 工具包,旨在简化 UI 开发。如果您是 Jetpack Compose 的新手,不妨在尝试本 Codelab 之前先尝试其他一些 Codelab。

您将学到什么

  • 如何使用几种基本动画 API

前提条件

您需要什么

2. 设置

下载 Codelab 代码。您可以通过以下方式克隆仓库

$ git clone https://github.com/android/codelab-android-compose.git

或者,您可以将仓库下载为 zip 文件

在 Android Studio 中导入 AnimationCodelab 项目。

Importing Animation Codelab into Android Studio

项目中有多个模块

  • start 是本 Codelab 的起始状态。
  • finished 是完成本 Codelab 后应用的最终状态。

确保在运行配置的下拉列表中选择了 start

Showing start selected in Android Studio

我们将在下一章开始处理几种动画场景。本 Codelab 中处理的每个代码片段都标有 // TODO 注释。一个巧妙的技巧是在 Android Studio 中打开 TODO 工具窗口,然后导航到该章的每个 TODO 注释。

TODO list shown in Android Studio

3. 为简单的值更改添加动画

我们从 Compose 中最简单的动画 API 之一开始:animate*AsState API。此 API 应用于为 State 更改添加动画。

运行 start 配置,然后尝试点击顶部的“Home”和“Work”按钮来切换标签页。它实际上并不会切换标签页内容,但您可以看到内容的背景颜色发生了变化。

Home Tab Selected

Work Tab Selected

在 TODO 工具窗口中点击 TODO 1,看看它是如何实现的。它位于 Home 可组合项中。

val backgroundColor = if (tabPage == TabPage.Home) Seashell else GreenLight

这里,tabPage 是由 State 对象支持的 TabPage。根据其值,背景颜色在浅粉色和绿色之间切换。我们希望为这个值更改添加动画。

为了为这种简单的值更改添加动画,我们可以使用 animate*AsState API。您可以通过使用相应的 animate*AsState 可组合项变体(在本例中为 animateColorAsState)包装更改的值来创建动画值。返回的值是一个 State<T> 对象,因此我们可以使用带有 by 声明的 局部委托属性,将其视为普通变量。

val backgroundColor by animateColorAsState(
        targetValue = if (tabPage == TabPage.Home) Seashell else GreenLight,
        label = "background color")

重新运行应用并尝试切换标签页。颜色变化现在带有动画效果。

Color Change animation in action between tabs

4. 为可见性添加动画

如果您滚动应用内容,您会注意到浮动操作按钮会根据您滚动的方向展开或收缩。

Edit Floating action button expanded

Edit Floating action button small

找到 TODO 2-1,查看它是如何工作的。它位于 HomeFloatingActionButton 可组合项中。使用 if 语句来显示或隐藏“EDIT”文本。

if (extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}

为这种可见性更改添加动画就像将 if 替换为 AnimatedVisibility 可组合项一样简单。

AnimatedVisibility(extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}

运行应用,看看 FAB 现在如何展开和收缩。

Floating action edit button animation

AnimatedVisibility 在指定的 Boolean 值每次更改时运行其动画。默认情况下,AnimatedVisibility 通过淡入和展开来显示元素,通过淡出和收缩来隐藏元素。这种行为非常适合 FAB 的示例,但我们也可以自定义行为。

尝试点击 FAB,您应该会看到一条消息:“Edit feature is not supported”。它也使用 AnimatedVisibility 为其出现和消失添加动画。接下来,您将自定义此行为,使消息从顶部滑入,并滑出到顶部。

Message detailing that the edit feature is not supported

找到 TODO 2-2,查看 EditMessage 可组合项中的代码。

AnimatedVisibility(
    visible = shown
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colorScheme.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

为了自定义动画,将 enterexit 参数添加到 AnimatedVisibility 可组合项中。

enter 参数应是 EnterTransition 的实例。对于此示例,我们可以使用 slideInVertically 函数创建 EnterTransition,并使用 slideOutVertically 用于退出过渡。按如下方式更改代码

AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(),
    exit = slideOutVertically()
)

再次运行应用,点击编辑按钮,您可能会注意到动画看起来更好,但不完全正确,这是因为 slideInVerticallyslideOutVertically 的默认行为使用了项目高度的一半。

Slide out vertically cuts off halfway

对于进入过渡:我们可以调整默认行为,使其使用项目的整个高度来正确地为其添加动画,方法是设置 initialOffsetY 参数。initialOffsetY 应该是一个返回初始位置的 lambda。

lambda 接收一个参数,即元素的高度。为了确保项目从屏幕顶部滑入,我们返回其负值,因为屏幕顶部的数值为 0。我们希望动画从 -height 开始到 0(其最终静止位置),以便它从上方开始并动画进入。

使用 slideInVertically 时,滑入后的目标偏移量始终为 0(像素)。initialOffsetY 可以指定为绝对值或元素完整高度的百分比(通过 lambda 函数)。

类似地,slideOutVertically 假定初始偏移量为 0,因此只需指定 targetOffsetY

AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(
        // Enters by sliding down from offset -fullHeight to 0.
        initialOffsetY = { fullHeight -> -fullHeight }
    ),
    exit = slideOutVertically(
        // Exits by sliding up from offset 0 to -fullHeight.
        targetOffsetY = { fullHeight -> -fullHeight }
    )
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colorScheme.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

再次运行应用,我们可以看到动画更符合我们的预期

Slide in animation with offset working

我们可以使用 animationSpec 参数进一步自定义动画。animationSpec 是许多动画 API 的常见参数,包括 EnterTransitionExitTransition。我们可以传入各种 AnimationSpec 类型之一,以指定动画值随时间如何变化。在本示例中,我们使用基于持续时间的简单 AnimationSpec。它可以通过 tween 函数创建。持续时间为 150 毫秒,缓动为 LinearOutSlowInEasing。对于退出动画,我们为 animationSpec 参数使用相同的 tween 函数,但持续时间为 250 毫秒,缓动为 FastOutLinearInEasing

结果代码应如下所示

AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(
        // Enters by sliding down from offset -fullHeight to 0.
        initialOffsetY = { fullHeight -> -fullHeight },
        animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing)
    ),
    exit = slideOutVertically(
        // Exits by sliding up from offset 0 to -fullHeight.
        targetOffsetY = { fullHeight -> -fullHeight },
        animationSpec = tween(durationMillis = 250, easing = FastOutLinearInEasing)
    )
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colorScheme.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

运行应用并再次点击 FAB。您现在可以看到消息从顶部滑入和滑出,使用不同的缓动函数和持续时间

Animation showing the edit message sliding in from the top

5. 为内容尺寸更改添加动画

应用内容中显示了几个主题。尝试点击其中一个,它应该会展开并显示该主题的正文。包含文本的卡片在正文显示或隐藏时会展开和收缩。

Collapsed topic list

Topic list expanded

查看 TopicRow 可组合项中 TODO 3 的代码。

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
) {
    // ... the title and the body
}

这里的 Column 可组合项会随着其内容的变化而改变大小。我们可以通过添加 animateContentSize 修饰符来为大小变化添加动画。

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
        .animateContentSize()
) {
    // ... the title and the body
}

运行应用并点击其中一个主题。您可以看到它会展开和收缩,带有动画效果。

Topic list expanded and collapse animation

animateContentSize 也可以通过自定义 animationSpec 进行自定义。我们可以提供选项来更改动画类型,例如从弹性动画 (spring) 到补间动画 (tween) 等。有关更多信息,请参阅自定义动画文档

6. 为多个值添加动画

现在我们熟悉了一些基本动画 API,接下来看看 Transition API,它允许我们创建更复杂的动画。使用 Transition API 可以跟踪 Transition 上的所有动画何时完成,这在使用之前看到的单独 animate*AsState API 时是不可能的。Transition API 还允许我们在不同状态之间进行过渡时定义不同的 transitionSpec。让我们看看如何使用它

在此示例中,我们自定义标签指示器。它是当前选定标签页上显示的矩形。

Home tab selected

Work tab selected

HomeTabIndicator 可组合项中找到 TODO 4,查看标签指示器是如何实现的。

val indicatorLeft = tabPositions[tabPage.ordinal].left
val indicatorRight = tabPositions[tabPage.ordinal].right
val color = if (tabPage == TabPage.Home) PaleDogwood else Green

这里,indicatorLeft 是指示器在标签行中左边缘的水平位置。indicatorRight 是指示器右边缘的水平位置。颜色也在浅粉色和绿色之间变化。

要同时为这些多个值添加动画,我们可以使用 TransitionTransition 可以通过 updateTransition 函数创建。将当前选定标签页的索引作为 targetState 参数传入。

每个动画值都可以使用 Transitionanimate* 扩展函数声明。在此示例中,我们使用 animateDpanimateColor。它们接受一个 lambda 块,我们可以为每个状态指定目标值。我们已经知道它们的目标值应该是什么,所以我们可以像下面这样包装这些值。请注意,我们这里可以再次使用 by 声明,并将其设为局部委托属性,因为 animate* 函数返回一个 State 对象。

val transition = updateTransition(tabPage, label = "Tab indicator")
val indicatorLeft by transition.animateDp(label = "Indicator left") { page ->
   tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(label = "Indicator right") { page ->
   tabPositions[page.ordinal].right
}
val color by transition.animateColor(label = "Border color") { page ->
   if (page == TabPage.Home) PaleDogwood else Green
}

现在运行应用,您可以看到标签页切换现在更加有趣。随着点击标签页改变 tabPage 状态的值,与 transition 相关的所有动画值都会开始动画到目标状态指定的值。

Animation between home and work tabs

此外,我们可以指定 transitionSpec 参数来自定义动画行为。例如,通过让靠近目的地的边缘移动得比另一条边缘更快,可以为指示器实现弹性效果。我们可以在 transitionSpec lambda 中使用 isTransitioningTo 中缀函数来确定状态变化的方向。

val transition = updateTransition(
    tabPage,
    label = "Tab indicator"
)
val indicatorLeft by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right.
            // The left edge moves slower than the right edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        } else {
            // Indicator moves to the left.
            // The left edge moves faster than the right edge.
            spring(stiffness = Spring.StiffnessMedium)
        }
    },
    label = "Indicator left"
) { page ->
    tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right
            // The right edge moves faster than the left edge.
            spring(stiffness = Spring.StiffnessMedium)
        } else {
            // Indicator moves to the left.
            // The right edge moves slower than the left edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        }
    },
    label = "Indicator right"
) { page ->
    tabPositions[page.ordinal].right
}
val color by transition.animateColor(
    label = "Border color"
) { page ->
    if (page == TabPage.Home) PaleDogwood else Green
}

再次运行应用并尝试切换标签页。

Custom elastic effect on tab switching

Android Studio 支持在 Compose 预览中检查 Transition。要使用动画预览,请在预览中可组合项的右上角点击“启动动画预览”图标 (动画预览图标 图标) 来启动互动模式。尝试点击 PreviewHomeTabBar 可组合项的图标。这将打开一个新的“动画”面板。

您可以通过点击“播放”图标按钮运行动画。您也可以在进度条上拖动以查看每个动画帧。为了更好地描述动画值,您可以在 updateTransitionanimate* 方法中指定 label 参数。

Seek animations in Android Studio

7. 重复动画

尝试点击当前温度旁边的刷新图标按钮。应用开始加载最新的天气信息(只是模拟)。在加载完成之前,您会看到一个加载指示器,它是一个灰色圆圈和一条进度条。我们来为这个指示器的 alpha 值添加动画,使其更清楚地表明过程正在进行中。

Static image of placeholder info card that is not animated yet.

LoadingRow 可组合项中找到 TODO 5

val alpha = 1f

我们希望这个值在 0f 和 1f 之间重复动画。我们可以为此使用 InfiniteTransition。此 API 与上一节中的 Transition API 类似。它们都可以为多个值添加动画,但 Transition 基于状态变化为值添加动画,而 InfiniteTransition 无限期地为值添加动画。

要创建 InfiniteTransition,请使用 rememberInfiniteTransition 函数。然后,可以使用 InfiniteTransitionanimate* 扩展函数之一声明每个动画值变化。在这种情况下,我们正在为 alpha 值添加动画,因此我们使用 animatedFloatinitialValue 参数应为 0ftargetValue 应为 1f。我们也可以为这个动画指定一个 AnimationSpec,但这个 API 只接受 InfiniteRepeatableSpec。使用 infiniteRepeatable 函数创建一个。这个 AnimationSpec 会包装任何基于持续时间的 AnimationSpec 并使其可重复。例如,结果代码应如下所示。

val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = keyframes {
            durationMillis = 1000
            0.7f at 500
        },
        repeatMode = RepeatMode.Reverse
    ),
    label = "alpha"
)

默认的 repeatModeRepeatMode.Restart。这会从 initialValue 过渡到 targetValue,然后从 initialValue 重新开始。通过将 repeatMode 设置为 RepeatMode.Reverse,动画会从 initialValue 进行到 targetValue,然后从 targetValue 进行到 initialValue。动画从 0 进行到 1,然后从 1 进行到 0。

keyFrames 动画是另一种 animationSpec 类型(其他一些类型包括 tweenspring),它允许在不同毫秒处更改进行中的值。我们最初将 durationMillis 设置为 1000 毫秒。然后我们可以定义动画中的关键帧,例如,在动画的 500 毫秒处,我们希望 alpha 值为 0.7f。这将改变动画的进程:它将在动画的 500 毫秒内从 0 快速 진행 到 0.7,然后在动画的 500 毫秒到 1000 毫秒内从 0.7 进行到 1.0,并在结束时减速。

如果我们需要多个关键帧,我们可以按如下方式定义多个 keyFrames

animation = keyframes {
   durationMillis = 1000
   0.7f at 500
   0.9f at 800
}

运行应用并尝试点击刷新按钮。您现在可以看到加载指示器正在动画。

Repeating animated placeholder content

8. 手势动画

在最后一部分,我们将学习如何基于触摸输入运行动画。我们将从头开始构建一个 swipeToDismiss 修饰符。

swipeToDismiss 修饰符中找到 TODO 6-1。这里,我们正在尝试创建一个修饰符,使元素可以通过触摸滑动。当元素被甩到屏幕边缘时,我们调用 onDismissed 回调,以便可以移除该元素。

要构建 swipeToDismiss 修饰符,我们需要理解几个关键概念。首先,用户将手指放在屏幕上,生成具有 x 和 y 坐标的触摸事件,然后他们会向右或向左移动手指——根据他们的移动来移动 x 和 y。他们触摸的项目需要随其手指移动,因此我们将根据触摸事件的位置和速度更新项目的位置。

我们可以使用Compose 手势文档中描述的几个概念。使用pointerInput 修饰符,我们可以低级别访问传入的指针触摸事件,并使用同一指针跟踪用户拖动的速度。如果他们在项目超过可解除边界之前松手,项目将弹回原位。

在这种场景下需要考虑一些独特的事情。首先,任何正在进行的动画都可能被触摸事件拦截。其次,动画值可能不是唯一的真实来源。换句话说,我们可能需要将动画值与来自触摸事件的值同步。

Animatable 是我们目前为止看到的最低级别 API。它在手势场景中具有多个有用的特性,例如能够立即吸附到手势传入的新值,以及在触发新的触摸事件时停止任何正在进行的动画。让我们创建一个 Animatable 实例,并用它来表示可滑动元素的水平偏移量。请确保从 androidx.compose.animation.core.Animatable 导入 Animatable,而不是从 androidx.compose.animation.Animatable 导入。

val offsetX = remember { Animatable(0f) } // Add this line
// used to receive user touch events
pointerInput {
    // Used to calculate a settling position of a fling animation.
    val decay = splineBasedDecay<Float>(this)
    // Wrap in a coroutine scope to use suspend functions for touch events and animation.
    coroutineScope {
        while (true) {
            // ...

TODO 6-2 处,我们刚刚收到了一个按下触摸事件。如果动画当前正在运行,我们应该拦截它。这可以通过在 Animatable 上调用 stop 来实现。请注意,如果动画未运行,此调用将被忽略。VelocityTracker 将用于计算用户从左到右移动的速度。awaitPointerEventScope 是一个 suspend 函数,它可以等待用户输入事件并对其进行响应。

// Wait for a touch down event. Track the pointerId based on the touch
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
offsetX.stop() // Add this line to cancel any on-going animations
// Prepare for drag events and record velocity of a fling gesture
val velocityTracker = VelocityTracker()
// Wait for drag events.
awaitPointerEventScope {

TODO 6-3 处,我们正在持续接收拖动事件。我们必须将触摸事件的位置同步到动画值中。为此,我们可以使用 Animatable 上的 snapTosnapTo 必须在另一个 launch 块内调用,因为 awaitPointerEventScopehorizontalDrag 是受限制的协程作用域。这意味着它们只能对 awaitPointerEvents 进行 suspend 操作,而 snapTo 不是指针事件。

horizontalDrag(pointerId) { change ->
    // Add these 4 lines
    // Get the drag amount change to offset the item with
    val horizontalDragOffset = offsetX.value + change.positionChange().x
    // Need to call this in a launch block in order to run it separately outside of the awaitPointerEventScope
    launch {
        // Instantly set the Animable to the dragOffset to ensure its moving
        // as the user's finger moves
        offsetX.snapTo(horizontalDragOffset)
    }
    // Record the velocity of the drag.
    velocityTracker.addPosition(change.uptimeMillis, change.position)

    // Consume the gesture event, not passed to external
    if (change.positionChange() != Offset.Zero) change.consume()

}

TODO 6-4 是元素刚刚被释放和甩出的地方。我们需要计算甩出最终稳定到的位置,以便决定是将元素滑回原始位置,还是将其滑走并调用回调。我们使用之前创建的 decay 对象来计算 targetOffsetX

// Dragging finished. Calculate the velocity of the fling.
val velocity = velocityTracker.calculateVelocity().x
// Add this line to calculate where it would end up with
// the current velocity and position
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)

TODO 6-5 处,我们即将开始动画。但在那之前,我们想为 Animatable 设置上限和下限值,以便它一到达边界就停止(-size.widthsize.width,因为我们不希望 offsetX 能够超出这两个值)。pointerInput 修饰符允许我们通过 size 属性访问元素的大小,所以我们使用它来获取边界。

offsetX.updateBounds(
    lowerBound = -size.width.toFloat(),
    upperBound = size.width.toFloat()
)

TODO 6-6 处,我们终于可以开始动画了。我们首先比较之前计算的甩出稳定位置和元素的大小。如果稳定位置小于大小,则表示甩出的速度不够。我们可以使用 animateTo 将值动画回 0f。否则,我们使用 animateDecay 开始甩出动画。当动画完成(很可能是通过我们之前设置的边界)时,我们可以调用回调。

launch {
    if (targetOffsetX.absoluteValue <= size.width) {
        // Not enough velocity; Slide back.
        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
    } else {
        // Enough velocity to slide away the element to the edge.
        offsetX.animateDecay(velocity, decay)
        // The element was swiped away.
        onDismissed()
    }
}

最后,请看 TODO 6-7。我们已经设置好所有动画和手势,所以不要忘记将偏移量应用到元素,这将把屏幕上的元素移动到手势或动画产生的值所指定的位置

.offset { IntOffset(offsetX.value.roundToInt(), 0) }

本节结束后,您将得到如下所示的代码

private fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    // This Animatable stores the horizontal offset for the element.
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate a settling position of a fling animation.
        val decay = splineBasedDecay<Float>(this)
        // Wrap in a coroutine scope to use suspend functions for touch events and animation.
        coroutineScope {
            while (true) {
                // Wait for a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                // Interrupt any ongoing animation.
                offsetX.stop()
                // Prepare for drag events and record velocity of a fling.
                val velocityTracker = VelocityTracker()
                // Wait for drag events.
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Record the position after offset
                        val horizontalDragOffset = offsetX.value + change.positionChange().x
                        launch {
                            // Overwrite the Animatable value while the element is dragged.
                            offsetX.snapTo(horizontalDragOffset)
                        }
                        // Record the velocity of the drag.
                        velocityTracker.addPosition(change.uptimeMillis, change.position)
                        // Consume the gesture event, not passed to external
                        change.consumePositionChange()
                    }
                }
                // Dragging finished. Calculate the velocity of the fling.
                val velocity = velocityTracker.calculateVelocity().x
                // Calculate where the element eventually settles after the fling animation.
                val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
                // The animation should end as soon as it reaches these bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back to the default position.
                        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
                    } else {
                        // Enough velocity to slide away the element to the edge.
                        offsetX.animateDecay(velocity, decay)
                        // The element was swiped away.
                        onDismissed()
                    }
                }
            }
        }
    }
        // Apply the horizontal offset to the element.
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

运行应用并尝试滑动其中一个任务项。您会看到元素根据您的甩出速度,要么滑回默认位置,要么滑走并被移除。您还可以在元素正在动画时捕获它。

Gesture Animation swipe to dismiss items

9. 恭喜!

恭喜!您已学习了基本的 Compose 动画 API。

在本 Codelab 中,我们学习了如何使用

高级动画 API

  • animatedContentSize
  • AnimatedVisibility

低级动画 API

  • 用于为单个值添加动画的 animate*AsState
  • 用于为多个值添加动画的 updateTransition
  • 用于无限期为值添加动画的 infiniteTransition
  • 用于构建带触摸手势的自定义动画的 Animatable

接下来是什么?

查看Compose 学习路径上的其他 Codelab。

要了解更多信息,请参阅Compose 动画和这些参考文档