2024-12-07
Kotlin & 客户端
00

目录

1 Android 版本代号速查
2 下拉菜单栏设计 && 状态管理?
2.1 menuAnchor修饰符
2.2 状态应该放在哪?
3 手势检测
3.1 combinedClickable,简单易用版的,只能支持单击,双击,长按
3.2 pointerInput,比较复杂,但是支持单击双击长按拖拽等等
4 FilterChip覆盖点击事件
5 DisposableEffect用法
6 自定义组件SlidePickerRow的设计
6.1 detectHorizontalDragGestures用法
6.2 内部逻辑设计
7 指定透明颜色
8 借助LifecycleOwner监控和感知生命周期
8.1 观察生命周期,并且触发某些操作
8.2 绑定生命周期
9 输入框修改键盘配置
10 用户焦点管理
11 通过AnimatedContent和AnimatedVisibility组件为组件快速应用动画
11.1 AnimatedContent
11.2 AnimatedVisibility
12 流的常用操作
12.1 combine
12.2 map
12.3 onEach
12.4 shareIn
12.5 flatMapLatest
12.6 debounce、distinctUntilChanged
13 搜索框实现 & Pager 库的基本使用
13.1 搜索逻辑
13.2 如果有多个搜索框交给同一个 ViewModel 进行管理怎么办
14 关于SharedFlow/MutableSharedFlow和StateFlow/MutableStateFlow
14.1 SharedFlow
14.2 StateFlow
14.3 这俩如何选择
15 MIME类型速查

主要内容为设计、完成 Task 模块的过程中,遇到的问题和值得记录的一些点。

另外,Android 版本代号速查也在这里。

真是好久没写了,好多问题攒下来之后再回看都想不起来了,再不写估计全忘光了。

1 Android 版本代号速查

版本API 级别VERSION_CODE
Android 16暂无暂无
Android 1535VANILLA_ICE_CREAM
Android 1434UPSIDE_DOWN_CAKE
Android 1333TIRAMISU
Android 12 L
32S_V2
Android 1231S
Android 1130R
Android 1029Q
Android 928P
Android 8.127O_MR1
Android 826O
Android 7.125N_MR1
Android 724N
Android 623M
Android 5.122LOLLIPOP_MR1
Android 5
21L(LOLLIPOP)
Android 4.4W20KITKAT_WATCH
Android 4
19KITKAT

2 下拉菜单栏设计 && 状态管理?

在写 Task 模块的时候,有一个需求是给任务选分类,包括也得给提醒选择提醒模式,都要用到这种下拉菜单栏,所以开写。完成之后是这样的:

8f0b3c2cd20b6898e80a6e6a8816eca.jpg

这里就是搭配OutlinedTextFieldExposedDropdownMenuBox两个组件完成。把编辑框设计成只读,并且回调设成空函数即可。

kotlin
interface DisplayFieldProvider { fun getDisplayField(): String } @OptIn(ExperimentalMaterial3Api::class) @Composable fun <T: DisplayFieldProvider> DropDownMenuWithOutlinedBorder( modifier: Modifier = Modifier.fillMaxWidth(), labelText: String, leadingIcon: @Composable (() -> Unit)? = null, allowNoChosen: Boolean = false, data: List<T>, value: T? = null, onChosen: (T?) -> Unit, ) { val expanded = remember { mutableStateOf(false) } ExposedDropdownMenuBox ( modifier = modifier, expanded = expanded.value, onExpandedChange = { expanded.value = it } ) { OutlinedTextField( modifier = Modifier .fillMaxWidth() .menuAnchor(type = MenuAnchorType.PrimaryNotEditable), value = value?.getDisplayField() ?: "", onValueChange = {}, trailingIcon = { IconButton( onClick = { expanded.value = true } ) { Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = "Show All Category") } }, label = { Text(text = labelText) }, leadingIcon = leadingIcon, readOnly = true, isError = (value == null) && !allowNoChosen ) ExposedDropdownMenu( expanded = expanded.value, onDismissRequest = { expanded.value = false }, ) { data.forEachIndexed { index, singleData -> DropdownMenuItem( modifier = Modifier.fillMaxWidth(), text = { Text(text = singleData.getDisplayField()) }, onClick = { onChosen.invoke(singleData) expanded.value = false }, trailingIcon = { if (value == singleData) { Icon(imageVector = Icons.Default.Done, contentDescription = "This Item is Chosen") } } ) } if (allowNoChosen) { DropdownMenuItem( modifier = Modifier.fillMaxWidth(), text = { Text(text = "不选择") }, onClick = { onChosen.invoke(null) expanded.value = false }, trailingIcon = { if (value == null) { Icon(imageVector = Icons.Default.Done, contentDescription = "This Item is Chosen") } } ) } } } }

2.1 menuAnchor修饰符

一个要记的是这个修饰符,修饰给OutlinedTextField用的。ExposedDropdownMenuBox组件会提供一个ExposedDropdownMenuBoxScope,在这个作用域内部的组件可以调用该修饰符函数。

kotlin
public abstract fun Modifier.menuAnchor( type: MenuAnchorType, enabled: Boolean = true ): Modifier

该修饰符函数的作用是标明处于作用域内部的这个组件的作用,以及是否响应对下拉菜单的控制。前者通过type参数控制,后者通过enabled参数控制。注意enabled参数只控制该组件是否影响下拉菜单,并不控制该组件本身的enabled功能(比如TextField,比如IconButton)。

MenuAnchorType共有三种:PrimaryNotEditablePrimaryEditableSecondaryEditable

  • PrimaryNotEditable:主锚点,配合只读的输入框,点击该组件会展开下拉菜单,并且焦点在下拉菜单上。
  • PrimaryEditable:主锚点,但是允许用户输入。点击该组件也会展开下拉菜单,但是键盘也会出现,并且焦点在键盘上。
  • SecondaryEditable:辅助锚点,可以给输入框里面的图标用,可以与主锚点并存。点击该组件也会展开下拉菜单,取决于是否有辅助服务(?),焦点将在下拉菜单或者键盘上。

一般情况下,主锚点够用了。

2.2 状态应该放在哪?

之所以有这个问题,一来是因为这个组件和viewModel数据初始化有一些冲突(之前的写法会有,现在没有了)。二来是因为完成这个模块以来的一些模糊的想法:既然某些状态一定会提交给ViewModel,那么组件内部还需要再维护自己的状态吗?

之前这个组件是这样的,传入一个initialData,根据这个参数初始化并且缓存该组件内部的一个选择索引状态和要显示的文字状态。这么写在data参数固定不变的时候是没问题的,但是一旦data参数依赖一个流,就会出现问题,即初始化状态错误,并且在重组的时候因为有缓存,这个错误没有得到修正。

假设data参数来源于这里:

kotlin
val categories = viewModel.categoriesFlow.collectAsStateWithLifecycle(initialValue = listOf())

那么运行的顺序是这样的:

  • 该组件开始初次组合,并且根据collectAsStateWithLifecycle函数的data拿到数据,是一个空列表。
  • 该组件内部根据这个空列表进行状态的缓存,自然是找不到可以匹配的数据,因此产生了错误的状态。
  • 这个流准备好,开始从数据库中发出数据。
  • 该组件检查到参数data发生改变,开始重组。但是因为内部的状态已经被缓存,因为重组无法再修正错误的状态。

其实这个问题不光data参数有,原来的value参数也有。只是这个参数是从ViewModel里来的,我可以通过ViewModel内部的类似isLoading这样的状态去控制这个组件开始绘制的时间。但是data参数不行,流什么时候准备好是我完全不能控制的。对于这种List类型的数据,我也没法通过发空列表来表示此时还未准备好,毕竟空列表也真的可以有意义。

既然控制组件绘制时间这边行不通,那么组件内部是否可以修改?这也就提到了之前的那个问题:状态应该放在哪? 组件内部可以缓存的状态可以有多少?哪些应该提到父组件里?哪些应该提到ViewModel里?

这个问题的答案其实是从官方的组件用法中来的。比如说下面这个经典用法:

kotlin
var testState by remember { mutableStateOf("") } OutlinedTextField( value = testState, onValueChange = { testState = it } )

一个OutlinedTextField组件内部需要维护的状态肯定不止一个字符串,比如说被点击与否直接决定label的位置,这就值得维护一个状态。很显然,对于这两种不同的状态,字符串选择上提给父组件,而label位置这种就选择内部维护。(嘛,翻了源码之后我肯定是知道其实OutlinedTextField内部的BasicTextField内部也维护一个字符串状态来的,只是说这个意思)

从这个示例里,算是总结(?)了几条用法:

  • 如果一个状态只影响该组件内部,也不被外部状态影响,那没必要上提。比如旋转按钮控制某些组件是否显示,这个旋转的状态就没必要提出去。
  • 如果一个状态是业务需要的,那最好提到外面去,提到ViewModel里最好(当然得考虑页面和ViewModel生命周期不同所带来的可能的影响)。
  • 如果一个状态会被外部数据(比如流)影响,那最好提到外面去,内部就不要缓存了。

3 手势检测

手势检测有两种修饰符可以实现,一个比较简单易用,另一个比较复杂,功能更多。

3.1 combinedClickable,简单易用版的,只能支持单击,双击,长按

combinedClickable有两种参数列表,但是都比较简单易用。

kotlin
public fun Modifier.combinedClickable( enabled: Boolean = true, onClickLabel: String? = null, role: Role? = null, onLongClickLabel: String? = null, onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, onClick: () -> Unit ): Modifier
kotlin
public fun Modifier.combinedClickable( interactionSource: MutableInteractionSource?, indication: Indication?, enabled: Boolean = true, onClickLabel: String? = null, role: Role? = null, onLongClickLabel: String? = null, onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, onClick: () -> Unit ): Modifier

role参数基本上不用管,剩下的其实就很好理解了。单击、双击,长按的回调还有是否启用。第二个参数列表需要说一下:

  • interactionSource参数要一个MutableInteractionSource,它的本质是一个Interaction的流,这也就意味着,可以通过它来监听用户和组件之间的交互状态。在下面的FilterChip覆盖点击事件中,就用到这个东西来共享FilterChipBox的交互状态,从而让FliterChip能对Box的交互事件作出响应。
  • indication参数用于指定组件的交互指示。用人话举例来说,就是用户点击之后组件的那个反馈(比如说波纹动画),就是这个参数。

3.2 pointerInput,比较复杂,但是支持单击双击长按拖拽等等

kotlin
public fun Modifier.pointerInput( key1: Any?, block: suspend PointerInputScope.() -> Unit ): Modifier

这个修饰符也有三种参数列表,但是区别都不大,只是在key上有数量的区别,在此就不表了。这个key参数类似其他的什么LaunchedEffect函数的key参数,作用都是一样的,在key参数发生改变时重新刷新block内部的逻辑。

提示

可能比较抽象的是,这些回调某种意义上也要算是一种“状态”,因为这些回调可以持有对外部变量的引用(可变变量)/值(不可变变量)。

想象这么一种情况:一个LazyColumnpointerInput内部使用了items函数给itemContent参数传入的Int值。如果这个Int值不变,那么皆大欢喜,pointerInput内部的逻辑可以正常工作;如果这个Int值变了呢?此时pointerInput内部仍然持有旧值,那么结果一定会出错。这就是key参数存在的意义。

参见:Why is my size variable not updating correctly in Jetpack Compose?

重点是这个PointerInputScope作用域。该作用域提供了以下几种扩展函数来检测手势:

  • detectTapGestures
  • detectDragGestures
  • detectHorizontalDragGestures
  • detectVerticalDragGestures
  • detectTransformGestures
  • detectDragGesturesAfterLongPress

见名知意,不再多表。常用的点击类手势去找detectTapGestures,内部参数也比较好理解(Offset是用户点击位置相对于父组件的位置):

kotlin
public suspend fun PointerInputScope.detectTapGestures( onDoubleTap: ((Offset) -> Unit)? = null, onLongPress: ((Offset) -> Unit)? = null, onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture, onTap: ((Offset) -> Unit)? = null ): ERROR

拖拽类的手势去找detectDragGesturesdetectHorizontalDragGesturesdetectVerticalDragGestures这仨,下面也会有用法,这里不再多表。剩下的两个扩展函数,一个是处理双指手势的(旋转,放大什么的),另一个是长按后再拖拽。

4 FilterChip覆盖点击事件

问题:FilterChip加了类似pointerInput这种修饰符之后,无法检测长按事件。

答案:Chip组件内部是基于Surface组件的,后者实现了内部的clickable修饰器。

答案来源:android - Detecting long press events on Chips in Jetpack Compose - Stack Overflow

解决方案:用一个Box先把FliterChip和一个无子组件的Box包在一起,并且内部的无组件Box尺寸和父Box一致,之后内部的FliterChipBox共享一个MutableInteractionSource,把长按检测加在内部的无组件Box上。

kotlin
val filterChipInteractionSource = remember { MutableInteractionSource() } Box { FilterChip ( selected = isSelected, onClick = {}, interactionSource = filterChipInteractionSource, label = { Text(text = entity.getDisplayField()) }, leadingIcon = if (isSelected) leadingCheckedIcon else null, ) Box ( modifier = Modifier .matchParentSize() // 和父组件保持尺寸一致 该修饰符只能在 Box 内部用 .combinedClickable( interactionSource = filterChipInteractionSource, indication = null, onClick = { if (isSelected) { selectedDataList.remove(entity) } else { selectedDataList.add(entity) } onSelectedDataChanged.invoke(selectedDataList.toList()) }, onLongClick = { editEntity.value = entity isShowPopUp.value = true } ) ) }

5 DisposableEffect用法

kotlin
public fun DisposableEffect( key1: Any?, effect: DisposableEffectScope.() -> DisposableEffectResult ): Unit

这个东西和LaunchedEffect差不多,但是它不会默认启动协程,同时要多一个功能,就是DisposableEffectScope的扩展函数onDispose。这个东西会在该组件被销毁时执行,可以帮助清理页面的各种资源一类的东西,比如下面的用法:

kotlin
fun <T: Identifiable> SealedSearchBar ( hideSearchBar: () -> Unit, dataSourceViewModel: SealedSearchBarInterface<T>, listItemComponent: @Composable (data: T, modifier: Modifier, onCloseSearchView: () -> Unit) -> Unit, ) { ... DisposableEffect (expanded) { if (expanded) { dataSourceViewModel.startSearch() } else { dataSourceViewModel.endSearch() } onDispose { dataSourceViewModel.endSearch() } } }

6 自定义组件SlidePickerRow的设计

在实现 Task 模块的时候,有一个想法是这样的:在时间线页面,可以通过左右滑动来跳转到日期的前一天或者是后一天,这个组件也就应运而生。

首先,这个组件的数据源必须可以是动态的,不然我肯定不能生成一年所有日期的一个列表来用;其次,要基于LazyColumn,它对动态生成的数据有很好的支持,并且能够对显示项进行控制,还能禁止对拖拽动作进行响应,简直完美;最后,每个列表项都要检测拖拽项,来响应用户的手势。

最终代码如下:

kotlin
/** * SlidePickerRow 一个左右滑动选择数据的组件 数据源通过 nextDateGenerator 和 previousDataGenerator 指定 * * dataList 能够指定初始的数据列表 到达边界后最自动尝试用 generator 来生成 默认情况下 最开始显示的是 dataList 里第一个元素 * * 数据可以有边界 在这两个 generator 里返回 null 就是边界了 * * keySelector 是给 lazyRow 用的 必须有一个唯一的 id 选择 * * 选择结果在 onPicked 回调中获取 * * component 决定了组件的具体内容 */ @Composable fun <T: Any> SlidePickerRow ( modifier: Modifier, rowState: LazyListState = rememberLazyListState(), dataList: SnapshotStateList<T>, nextDataGenerator: ((currentData: T) -> T?), previousDataGenerator: ((currentData: T) -> T?), keySelector: (T) -> Any, onPicked: (T) -> Unit, component: @Composable (T) -> Unit ) { // TODO: 这里的设计可以写博客 val rollBackState = remember { mutableStateOf(false) } val isAddState = remember { mutableStateOf(false) } val isMinusState = remember { mutableStateOf(false) } val swipeThreshold = 300f val scope = rememberCoroutineScope() LazyRow ( modifier = modifier, state = rowState, userScrollEnabled = false, ) { items(count = dataList.size, key = { keySelector.invoke(dataList[it]) }) { index -> val offsetX = remember { Animatable(0f) } val data = dataList[index] Box ( modifier = Modifier .animateItem() .fillParentMaxWidth() .pointerInput(index) { detectHorizontalDragGestures( onDragEnd = { scope .launch { if (isAddState.value) { if (index == dataList.size - 1) { // 需要生成元素 val nextData = nextDataGenerator.invoke(data) if (nextData != null) { dataList.add(nextData) rowState.animateScrollToItem(index + 1) } else { SnackBarController.sendSnackBarEvent( event = SnackBarEvent( message = "已无法再向后选择", duration = SnackbarDuration.Short ) ) offsetX.animateTo(0f) } } else { // 直接导 if (rowState.canScrollForward) { rowState.animateScrollToItem(index + 1) } else { SnackBarController.sendSnackBarEvent( event = SnackBarEvent( message = "已无法再向后选择", duration = SnackbarDuration.Short ) ) offsetX.animateTo(0f) } } isAddState.value = false } else if (isMinusState.value) { if (index == 0) { // 需要生成元素 val previousData = previousDataGenerator.invoke(data) if (previousData != null) { dataList.add(0, previousData) rowState.animateScrollToItem(index) } else { SnackBarController.sendSnackBarEvent( event = SnackBarEvent( message = "已无法再向前选择", duration = SnackbarDuration.Short ) ) } /* 这里之所以要导航到 index 上 是因为即使外部的 index 发生了变化 即使 pointerInput 已经重新初始化 这里的协程依然会执行完毕 也就是说 在上面的修改 dataList 之后 外部的这个组件的 index 已经从0变成1了 但是协程内部的这个 index 依然是0 导航也依然是要去0的 这个结论对下面的 index - 1 也是一样的 */ } else { // 不需要生成元素 直接导航 if (rowState.canScrollBackward) { rowState.animateScrollToItem(index - 1) } else { SnackBarController.sendSnackBarEvent( event = SnackBarEvent( message = "已无法再向前选择", duration = SnackbarDuration.Short ) ) offsetX.animateTo(0f) } } isMinusState.value = false } else if (rollBackState.value) { offsetX.animateTo(0f) rollBackState.value = false } } .invokeOnCompletion { onPicked.invoke(dataList[rowState.firstVisibleItemIndex]) } } ) { change, dragAmount -> change.consume() scope.launch { (offsetX.value + dragAmount).let { if (it in -swipeThreshold..swipeThreshold) { offsetX.snapTo(it) rollBackState.value = true } else if (it > swipeThreshold) { // 右滑 向前 isMinusState.value = true } else if (it < -swipeThreshold) { // 左滑 向后 isAddState.value = true } } } } } .offset { IntOffset(offsetX.value.roundToInt(), 0) }, contentAlignment = Alignment.Center ) { component.invoke(data) } } } }

下面就挑几个点说一下:

6.1 detectHorizontalDragGestures用法

这个扩展函数是用于检测水平拖拽动作的,上参数表:

kotlin
public suspend fun PointerInputScope.detectHorizontalDragGestures( onDragStart: (Offset) -> Unit = { }, onDragEnd: () -> Unit = { }, onDragCancel: () -> Unit = { }, onHorizontalDrag: (PointerInputChange, Float) -> Unit ): Unit

前俩不表,第三个参数用于本次手势被别的检测并且消费之后触发;最后一个参数会在拖拽事件发生后触发,并且传入这次事件和水平方向上的拖拽距离。

这最后一个参数有点难懂,它实际上是这么理解的:当用户在屏幕上横划的时候,这整个的横划动作是归detectHorizontalDragGestures的前两个参数管。实际上底层并不是这样的,一个横划动作会被系统理解为多个触摸位置改变的事件(也就是PointerInputChange这个东西)。每当用户的触摸位置改变一定距离(注释里说这个检测逻辑是根据AwaitPointerEventScope.awaitHorizontalTouchSlopOrCancellation扩展函数,这个函数的依据来源是ViewConfiguration.touchSlop)之后,就会被认为是一次“水平拖拽”(此拖拽是“多个触摸位置改变事件”层次上的拖拽,并不是指整个的从用户按下到松手的拖拽动作),并且立即触发onHorizontalDrag回调。

所以按照这些参数的含义,这东西就可以这么使:

kotlin
val swipeThreshold = 300f val offsetX = remember { Animatable(0f) } // 组件位置 Box ( modifier = Modifier .pointerInput(Unit) { detectHorizontalDragGestures( onDragEnd = { // 在这里写用户松手后的逻辑 } ) { change, dragAmount -> change.consume() // 先消费这次事件 scope.launch { // 开始根据这次事件的移动距离 移动这个组件 (offsetX.value + dragAmount).let { if (it in -swipeThreshold..swipeThreshold) { // 判断阈值 offsetX.snapTo(it) // 在阈值里 移动 rollBackState.value = true } else if (it > swipeThreshold) { // 右滑超过阈值 可以触发对应事件 isMinusState.value = true } else if (it < -swipeThreshold) { // 左滑超过阈值 可以触发对应事件 isAddState.value = true // 这俩状态是在 onDragEnd 里面用的 用于判断这次整个的拖拽距离是否超过阈值来触发对应事件 } } } } } .offset { IntOffset(offsetX.value.roundToInt(), 0) // 确定组件位置 }, ) { ... }

6.2 内部逻辑设计

这个“内部逻辑设计”指的是如何处理数据,如何处理事件,如何移动列表一类的逻辑。说实话这个组件真不想写第二遍

这个组件的大体思路就是:

  • 通过datalistnextDataGeneratorpreviousDataGenerator确定展示的数据,并且把展示的数据和组件写到items函数里。
  • 对于LazyRow组件,需要先通过userScrollEnabled参数来控制这个组件不响应滑动事件。
  • 通过detectHorizontalDragGestures这个函数拿到用户滑动的距离,并且判断是否超过阈值,是左滑还是右滑。
  • onDragEnd参数里,开始处理滑动结束后的事务:
    1. 判断是否超过阈值,如果确实没超过阈值,则原有的组件需要滑回去。
    2. 如果超过,则需要判断左滑还是右滑。
    3. 如果滑动不需要调用nextDataGeneratorpreviousDataGenerator来生成新的列表项时,直接通过rowState来导航到下一个/上一个列表项即可。
    4. 如果滑动到达边界,需要生成新的列表项时,则需要分类讨论:
      • 右滑好说,直接调用nextDataGenerator参数生成新的列表项(判空)加在datalist尾部,然后导航到当前的索引+1即可。
      • 左滑就需要讨论了,原因在于:左滑意味着需要向列表项头部添加元素,而这样做会刷新所有列表项的索引,包括当前列表项的索引。既然所有列表项的索引都变化了,那么pointerInput这个修饰符本身也需要通过指定key的方式重新初始化(detectHorizontalDragGestures参数捕获了外部的索引值)。但是,onDragEnd里启动的协程依然会运行。因此即使索引值发生变化,onDragEnd里的逻辑也和没变化是一致的,也就是为什么代码中会导航到indexindex - 1这两个位置,而不是index - 1index - 2
    5. 最后触发回调,更新被选中的列表项。

7 指定透明颜色

可以通过Color.Transparent来指定透明颜色,它的定义是:

kotlin
@Stable val Transparent = Color(0x00000000)

至于为什么这都需要特意写一笔,我只能说这就是谷歌的文档的可查询性。

8 借助LifecycleOwner监控和感知生命周期

鉴于在compose下的页面和Activity是完全分离的状态,那么假如我想要在页面每次开始/重启时执行一些操作来更新页面,就有两种操作:

  1. 借助Activity的生命周期函数,在onCreateonResume等函数中直接操作viewModel,来达到更新页面的目的。
  2. 借助LifecycleOwner拿到距离当前Composable函数最近的组件的生命周期。(这里的组件指的是Acitivity,Fragment这种组件,并非compose里面的显示组件)

借助LifecycleOwner监控生命周期有两个比较常用的写法:

8.1 观察生命周期,并且触发某些操作

kotlin
@Composable fun SettingView( viewModel: SettingViewModel = hiltViewModel(), onBack: () -> Unit, ) { val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val lifecycle = lifecycleOwner.lifecycle // 拿到生命周期 val observer = LifecycleEventObserver { _, event -> // 观察者 if (event == Lifecycle.Event.ON_RESUME) { // 观察生命周期 ... } } lifecycle.addObserver(observer) onDispose { // 一定要在 onDispose 中取消掉观察者 防止潜在意外操作 lifecycle.removeObserver(observer) } } }

8.2 绑定生命周期

kotlin
/** * ObserveAsEvents 获取当前页面所关联的生命周期 监听它 并且在它处于 Lifecycle.State.STARTED 状态时 开始 collect 流到 onEvent 函数中 * * 注意 collect 操作是在 Main 线程进行的 */ @Composable fun <T> ObserveAsEvents ( flow: Flow<T>, key1: Any? = null, key2: Any? = null, onEvent: (T) -> Unit, ) { val lifeCycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifeCycleOwner, flow, key1, key2) { lifeCycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { withContext(Dispatchers.Main.immediate) { flow.collect(onEvent) } } } }

9 输入框修改键盘配置

为输入框指定键盘类型,或者修改键位的行为等等。

kotlin
OutlinedTextField( value = textState.value, onValueChange = { textState.value = it }, label = { Text(text = labelText) }, keyboardOptions = KeyboardOptions.Default.copy( keyboardType = KeyboardType.Number, // 决定键盘类型 这里是数字键盘 imeAction = ImeAction.Done // 决定键盘右下角按钮的行为 这里将其修改为 Done 提交 ), keyboardActions = KeyboardActions( onDone = { // 在这里写 Done 的行为 } ) )

这个键盘类型和右下角按钮的行为并不止上面这一种,只是库里这些常量/函数名字都比较易懂,所以不再说明。

10 用户焦点管理

常用FocusManagerFocusRequesteronFocusChanged修饰符来管理组件是否聚焦于某个组件上。

比如以下组件,会在用户点击键盘的某个按钮后自动清除焦点。

kotlin
@Composable fun DynamicHeightOutlinedTextField( modifier: Modifier, textState: MutableState<String>, labelText: String, ) { val isFocused = remember { mutableStateOf(false) } val focusRequester = FocusRequester() val focusManager = LocalFocusManager.current OutlinedTextField( value = textState.value, onValueChange = { textState.value = it }, label = { Text(text = labelText) }, modifier = Modifier .then( if (isFocused.value) { modifier.heightIn(min = 300.dp) } else { modifier } ) .focusRequester(focusRequester) // 设置 FocusRequester .onFocusChanged { focusState -> // 检测焦点变化 isFocused.value = focusState.isFocused }, keyboardOptions = KeyboardOptions.Default.copy( imeAction = ImeAction.Done ), keyboardActions = KeyboardActions( onDone = { focusManager.clearFocus() // 在 Done 被点击时 清除全局焦点 } ) ) }

11 通过AnimatedContentAnimatedVisibility组件为组件快速应用动画

AnimatedContentAnimatedVisibility是能够快速为组件应用变化动画的一个组件。它们俩都能够在目标状态发生变化时自动开始动画。

11.1 AnimatedContent

使用示例:

kotlin
@Composable fun DateAlertView( viewModel: TaskViewModel = hiltViewModel(), onTaskClicked: (TaskWithTagWithFileWithCategory) -> Unit, ) { val isShowDatePickerState = remember { mutableStateOf(false) } Column( modifier = Modifier.fillMaxSize() ) { AnimatedContent ( targetState = isShowDatePickerState.value, // 目标状态 当它发生变化时开始过渡动画 transitionSpec = { // 指定过渡动画 ContentTransform( targetContentEnter = fadeIn(tween(300)), // 进入动画 initialContentExit = fadeOut(tween(300)), // 消失动画 sizeTransform = SizeTransform { initialSize, targetSize -> tween( durationMillis = 300, easing = LinearOutSlowInEasing ) } ) }, label = "Date Picker Change Animate" ) { // 在这里拿到目标状态 if (it) { ... } else { ... } } } }

11.2 AnimatedVisibility

某种意义上算是AnimatedContent的一个特殊用法,即控制一个组件的显示/消失动画。

注意,该组件的消失动画结束后,内部的组件会自动退出绘制树,并且取消组合。

kotlin
AnimatedVisibility( visible = !isShowSearchBar.value, enter = fadeIn(tween(300)), exit = fadeOut(tween(300)) ) { FloatingActionButton( onClick = { navController.navigate("addTask") }, contentColor = primaryColor, shape = CircleShape ) { Icon(imageVector = Icons.Default.Add, contentDescription = "Add New Task") } }

12 流的常用操作

12.1 combine

combine函数的作用是合并流,形成一个新流。只要它所依赖的流的其中一个发射了新值,那么transform参数就会按照所有依赖的流的最新的值执行一遍,并且发射新值。

使用示例:

kotlin
val taskInfoFlowAfterFilter = combine ( allTaskInfoFlow, selectedCategoriesList, selectedTagsList ) { taskList, categoriesList, tagsList -> // 对应三个依赖的流的最新的值 ... // 处理并且发射值 }

12.2 map

对流中的每一个所发射的值,按transform参数进行一次处理,并且返回这样得到的新流。

kotlin
val taskWithDeadlineFlow = taskInfoFlowAfterFilter .map { it.filter { it.task.deadLine != null && it.task.deadLine.isAfter(LocalDateTime.now()) }.groupBy { taskInfo -> taskInfo.task.deadLine!!.toLocalDate() }.toSortedMap() }

12.3 onEach

在上游的流的新值向下传递之前,对每个新值应用action参数。

注意,该操作符返回的流和原来的流类型一致

kotlin
val allTaskInfoFlow = taskRepository.getTasksInfoInFlow() .shareIn(viewModelScope, SharingStarted.Lazily, replay = 1) .onEach { // 自动检查是否过期 val expiredTaskUUID = mutableListOf<String>() val time = LocalDateTime.now() it.forEach { taskInfo -> taskInfo.task.deadLine?.let { if (it.isBefore(time) && taskInfo.task.isActive) { expiredTaskUUID.add(taskInfo.task.taskUUID) } } } if (expiredTaskUUID.isNotEmpty()) { viewModelScope.launch(Dispatchers.IO) { taskRepository.unableExpiredTask(expiredTaskUUID) } } }

12.4 shareIn

该函数用于将一个冷流转换为热流(热流:SharedFlow<T>),从而允许多个订阅者共享该流的数据,避免每个订阅者collect时上游数据源都要重复操作。

提示

冷流和热流是流的两种不同形式。

冷流没有订阅者就不工作,只有在有新的订阅者时才开始收集数据然后发射出去,并且每个订阅者都有独立的数据流(这也意味着每个订阅者都需要上游重新执行一次操作),彼此互不干扰。冷流的生命周期完全取决于有无订阅者。

与冷流相对的,热流能够独立工作,能够持有独立的生命周期,不依赖订阅者的存在来维持自身存活。并且,热流的所有订阅者共享同一份数据。

该函数的三个参数作用如下:

  • scope: CoroutineScope:指定该流运行在哪个协程上,如果该协程的作用域取消,则该流随之结束。
  • started: SharingStarted:指定该流的启动策略,有EagerlyLazilyWhileSubscribed(stopTimeoutMillis)三种。
  • replay: Int:重放数量,决定每个新的订阅者能够收到多少条历史数据。
kotlin
val allTaskInfoFlow = taskRepository.getTasksInfoInFlow() .shareIn(viewModelScope, SharingStarted.Lazily, replay = 1)

另外,该函数的官方文档中也有写一些常见操作,比如热流初始化值,捕获异常,指定缓存大小,等等,在此不表。

12.5 flatMapLatest

这个函数其实没用过,但是我觉得挺好玩的,而且感觉以后真说不定要用,就记一笔。

该函数用于形成一个新流(字面义),当上游的流发射一个新值时,transform参数就会执行、返回一个新流,并且取消掉以前由tranform形成的流。

示例:

kotlin
flow { emit("a") delay(100) emit("b") }.flatMapLatest { value -> flow { emit(value) delay(200) emit(value + "_last") } }

预期结果:a b b_last

12.6 debouncedistinctUntilChanged

这两个修饰符常用于从组件到 ViewModel 层的流,用于防止流短时间内发射过多的值。

  • debounce:延迟一段时间后,发送最新值
  • distinctUntilChanged:只发送不重复的值

示例:

kotlin
override fun startSearch() { searchJob?.cancel() searchJob = viewModelScope.launch(Dispatchers.IO) { keywordFlow .debounce(300) .distinctUntilChanged() .flatMapLatest { keyword -> taskRepository.searchTask(keyword) } .cachedIn(viewModelScope) .collectLatest { resultFlow.value = it } } }

13 搜索框实现 & Pager 库的基本使用

这个需求就不多说了,很常见。我想要的是搜索框作为一个通用组件,其数据,搜索逻辑和组件本身是解耦的,通过同一个接口互相配合使用。因此就有了以下组件:

kotlin
// 该接口需要被作为数据源的 viewModel 实现 作为搜索逻辑 interface SealedSearchBarInterface <T: Identifiable> { val keywordFlow: MutableStateFlow<String> val resultFlow: MutableStateFlow<PagingData<T>> var searchJob: Job? fun getSearchKeywordState(): String { return keywordFlow.value } fun setSearchKeywordState(keyword: String) { keywordFlow.value = keyword } fun startSearch() fun endSearch() } interface Identifiable { fun getKey(): Any }
kotlin
/** * SealedSearchBar 通用的搜索框组件 * * dataSourceViewModel 要求传入的是一个 viewModel 并且实现 SealedSearchBarInterface 接口 * * 这个 listItemComponent 的 onCloseSearchView 参数 是为了点击这个组件之后自动关闭搜索页面用的 */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun <T: Identifiable> SealedSearchBar ( hideSearchBar: () -> Unit, dataSourceViewModel: SealedSearchBarInterface<T>, listItemComponent: @Composable (data: T, modifier: Modifier, onCloseSearchView: () -> Unit) -> Unit, ) { var expanded by rememberSaveable { mutableStateOf(false) } val textFieldState = dataSourceViewModel.keywordFlow.collectAsState("") val searchResultState = dataSourceViewModel.resultFlow.collectAsLazyPagingItems() DisposableEffect (expanded) { if (expanded) { dataSourceViewModel.startSearch() } else { dataSourceViewModel.endSearch() } onDispose { dataSourceViewModel.endSearch() } } SearchBar ( modifier = Modifier .fillMaxWidth(), inputField = { SearchBarDefaults.InputField( query = textFieldState.value, onQueryChange = { dataSourceViewModel.keywordFlow.value = it }, onSearch = { expanded = false }, expanded = expanded, onExpandedChange = { expanded = it }, placeholder = { Text("搜索") }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search Bar Input Field") }, trailingIcon = { IconButton( onClick = { if (textFieldState.value.isNotBlank()) { dataSourceViewModel.keywordFlow.value = "" } else { hideSearchBar.invoke() } } ) { Icon(Icons.Default.Clear, contentDescription = "Clear Field or Exit Search Bar") } }, ) }, expanded = expanded, onExpandedChange = { expanded = it }, ) { AnimatedContent( targetState = searchResultState.loadState.refresh is LoadState.Loading, label = "Show Search Result or No Result" ) { if (it) { Box (Modifier.fillMaxSize(), Alignment.Center) { CircularProgressIndicator() } } else if (searchResultState.itemCount == 0) { Box (Modifier.fillMaxSize(), Alignment.Center) { NoResultComponent() } } else { LazyColumn { items( count = searchResultState.itemCount, key = searchResultState.itemKey { it.getKey() }, ) { searchResultState[it]?.let { listItemComponent.invoke( it, Modifier .animateItem() .padding(horizontal = 8.dp, vertical = 5.dp) ) { expanded = false hideSearchBar.invoke() } } } } } } } }

该组件的使用示例:

Pager 库的基本使用方法也在下面的示例里了,不再另行说明。关于 Pager 库的其他高级用法,在之后联网的部分会一并着重说明。

kotlin
@OptIn(ExperimentalMaterial3Api::class) @Composable fun ShowTaskView( viewModel: TaskViewModel = hiltViewModel(), navController: NavController, onShowNavigationDrawer: () -> Unit, ) { val isShowSearchBar = remember { mutableStateOf(false) } val isShowTaskDetail = remember { mutableStateOf(false) } val selectTaskDetailState = viewModel.selectedTaskDetailState.collectAsStateWithLifecycle(null) // selectedTaskUUIDForDetailViewState 把选中的 task 的 uuid 传回 viewModel viewModel 按 uuid 找到对应 task 之后再通过这个传回来 val taskInfoHandler: (TaskWithTagWithFileWithCategory) -> Unit = { viewModel.selectedTaskUUIDForDetailViewState.value = it.task.taskUUID isShowTaskDetail.value = true } ScaffoldWithGlobalSnackBarNotification( modifier = Modifier.fillMaxSize(), topBar = { AnimatedContent( targetState = isShowSearchBar.value, label = "Show Search Bar Animate" ) { if (it) { SealedSearchBar ( hideSearchBar = { isShowSearchBar.value = false }, dataSourceViewModel = viewModel, ) { taskInfo, animateModifier, onCloseSearchView -> TaskCardWithSwipePlusMinus ( modifier = animateModifier, taskInfo = taskInfo, isNeedTimeline = false, ) { taskInfo -> taskInfoHandler.invoke(taskInfo) onCloseSearchView.invoke() } } } else { ... } } } ){ ... } }
kotlin
@HiltViewModel class TaskViewModel @Inject constructor( private val taskRepository: TaskRepository, private val tagRepository: TagRepository, ): ViewModel(), SealedSearchBarInterface<TaskWithTagWithFileWithCategory> { ... // 实现 SealedSearchBarInterface override val keywordFlow: MutableStateFlow<String> = MutableStateFlow("") override val resultFlow: MutableStateFlow<PagingData<TaskWithTagWithFileWithCategory>> = MutableStateFlow(PagingData.empty<TaskWithTagWithFileWithCategory>()) override var searchJob: Job? = null @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) override fun startSearch() { searchJob?.cancel() searchJob = viewModelScope.launch(Dispatchers.IO) { keywordFlow .debounce(300) .distinctUntilChanged() .flatMapLatest { keyword -> taskRepository.searchTask(keyword) } .cachedIn(viewModelScope) .collectLatest { resultFlow.value = it } } } override fun endSearch() { searchJob?.cancel() resultFlow.value = PagingData.empty() } override fun onCleared() { searchJob?.cancel() super.onCleared() } } // repository里的方法 suspend fun searchTask(keyword: String): Flow<PagingData<TaskWithTagWithFileWithCategory>> { return Pager( config = PagingConfig(pageSize = 20), pagingSourceFactory = { if (keyword.isEmpty()) { dao.queryTaskWithTagWithFileWithCategoryInPage() } else { dao.searchTask(keyword, 20, 0) } } ).flow } // dao里的方法 @Transaction @Query(""" select * from task left join alert a on task.taskUUID = a.taskUUID left join taskCategory c on c.id = task.id left join tasktagref ttr on ttr.taskUUID = task.taskUUID left join tag t on t.tagId = ttr.tagId left join taskfileref tfr on tfr.taskUUID = task.taskUUID left join file f on f.fileUUID = tfr.fileUUID where task.taskName like '%' || :keyword || '%' or task.taskComment like '%' || :keyword || '%' or a.comment like '%' || :keyword || '%' or c.categoryName like '%' || :keyword || '%' or t.tagName like '%' || :keyword || '%' or f.fileName like '%' || :keyword || '%' or f.fileComment like '%' || :keyword || '%' order by task.deadLine desc limit :limit offset :offset """) fun searchTask(keyword: String, limit: Int, offset: Int): PagingSource<Int, TaskWithTagWithFileWithCategory> @Transaction @Query("select * from task where isActive = true order by deadLine desc") fun queryTaskWithTagWithFileWithCategoryInPage(): PagingSource<Int, TaskWithTagWithFileWithCategory>

13.1 搜索逻辑

在数据库里的搜索逻辑是没什么可说的,就嗯like就行。主要是viewModel层上的操作:

数据库内可以考虑上 fts 来进行全文搜索,比嗯like快。

kotlin
override fun startSearch() { searchJob?.cancel() searchJob = viewModelScope.launch(Dispatchers.IO) { keywordFlow .debounce(300) // 推迟发射新值 .distinctUntilChanged() // 只发射和上一个值不同的新值 这个和上一个一起 避免用户输入过于频繁 从而消耗大量资源 .flatMapLatest { keyword -> // 获取对应的流 taskRepository.searchTask(keyword) } .cachedIn(viewModelScope) // 给 Pager 库专用的缓存函数 注意它并不会将冷流转换为热流 仅缓存 Pager 数据 .collectLatest { // 仅保留最新发射的项 并且取消当前正在处理的项 // 也就是说 当新的 PagerData 被发送过来的时候 会立即取消上一个 PagerData 以最新的 PagerData 执行以下代码 resultFlow.value = it } } } override fun endSearch() { searchJob?.cancel() resultFlow.value = PagingData.empty() // 还原状态 } override fun onCleared() { // 确保 searchJob 的生命周期不会超过 viewModel searchJob?.cancel() super.onCleared() }

13.2 如果有多个搜索框交给同一个 ViewModel 进行管理怎么办

kotlin
SealedSearchBar( dataSourceViewModel = viewModel.searchObject1, hideSearchBar = TODO(), ) { } SealedSearchBar( dataSourceViewModel = viewModel.searchObject2, hideSearchBar = TODO(), ) { } @HiltViewModel class SettingViewModel @Inject constructor( @ApplicationContext val context: Context, val dataStore: SettingDateStore, val themeManager: ThemeManager, ): ViewModel() { val searchObject1 = object : SealedSearchBarInterface<Task> { override val keywordFlow: MutableStateFlow<String> get() = TODO("Not yet implemented") override val resultFlow: MutableStateFlow<PagingData<Task>> get() = TODO("Not yet implemented") override var searchJob: Job? get() = TODO("Not yet implemented") set(value) {} override fun startSearch() { TODO("Not yet implemented") } override fun endSearch() { TODO("Not yet implemented") } } val searchObject2 = object : SealedSearchBarInterface<Task> { override val keywordFlow: MutableStateFlow<String> get() = TODO("Not yet implemented") override val resultFlow: MutableStateFlow<PagingData<Task>> get() = TODO("Not yet implemented") override var searchJob: Job? get() = TODO("Not yet implemented") set(value) {} override fun startSearch() { TODO("Not yet implemented") } override fun endSearch() { TODO("Not yet implemented") } } override fun onCleared() { searchObject1.searchJob?.cancel() searchObject2.searchJob?.cancel() super.onCleared() } }

14 关于SharedFlow/MutableSharedFlowStateFlow/MutableStateFlow

先说明,为什么 ViewModel 层不用冷流,而是使用这两个热流?在12.4中说明过冷流和热流的区别。正因为冷流对每个订阅者都有独立的数据流和单独的上游数据源的操作(收集这个动作会触发上游数据源的代码),所以对于 ViewModel 中这种可能有多个订阅者的状态流是不建议用的,太容易造成资源浪费。至于 Repository 层传上来的冷流也都建议通过shareIn或者stateIn函数进行转换。

SharedFlowStateFlow这两个都是 ViewModel 中常用的状态流,用于数据向 View 层流动和事件向 ViewModel 层流动,在这里说明一下两者的区别。

参照官方文档:StateFlow 和 SharedFlow

14.1 SharedFlow

按官方定义,它是一个状态容器式可观察数据流a state-holder observable flow)。首先,它是一个热流,所以它具有热流所具备的特性:可管理生命周期,可重放,可缓存数据,多订阅者不会触发多次数据源代码。它可以通过sharedIn函数从一个冷流转换而来

使用示例:

kotlin
class TickHandler( private val externalScope: CoroutineScope, private val tickIntervalMs: Long = 5000 ) { private val _tickFlow = MutableSharedFlow<Unit>(replay = 0) // 创建 SharedFlow val tickFlow: SharedFlow<Event<String>> = _tickFlow // 暴露给外部的不可变流 init { externalScope.launch { while(true) { _tickFlow.emit(Unit) delay(tickIntervalMs) } } } } class NewsRepository( ..., private val tickHandler: TickHandler, private val externalScope: CoroutineScope ) { init { externalScope.launch { // Listen for tick updates tickHandler.tickFlow.collect { refreshLatestNews() } } } suspend fun refreshLatestNews() { ... } ... }

创建一个SharedFlow需要三个参数:

kotlin
public fun <T> MutableSharedFlow( replay: Int = 0, extraBufferCapacity: Int = 0, onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND )
  • replay:重放数,对于每个新的订阅者,会发送多少历史数据
  • extraBufferCapacity:缓冲区大小。
  • onBufferOverflow:缓存区溢出策略,顾名思义,缓存区溢出时可以选择去掉最旧/最新数据,或者挂起emit操作。缓冲区溢出仅在至少有一个订阅者尚未准备好接收新值时才会发生。

14.2 StateFlow

它有两个需要特别说明的性质:

  1. 某种意义上,它是升级版的State,如果只是 ViewModel 和 View 层交换数据,那么State就够用了;StateFlow为它添加了“可观察”这个特性。
  2. 它是特化版本的SharedFlow,详见它的定义:
kotlin
public interface MutableStateFlow<T> : StateFlow<T>, MutableSharedFlow<T> { /** * The current value of this state flow. * * Setting a value that is [equal][Any.equals] to the previous one does nothing. * * This property is **thread-safe** and can be safely updated from concurrent coroutines without * external synchronization. */ public override var value: T /** * Atomically compares the current [value] with [expect] and sets it to [update] if it is equal to [expect]. * The result is `true` if the [value] was set to [update] and `false` otherwise. * * This function use a regular comparison using [Any.equals]. If both [expect] and [update] are equal to the * current [value], this function returns `true`, but it does not actually change the reference that is * stored in the [value]. * * This method is **thread-safe** and can be safely invoked from concurrent coroutines without * external synchronization. */ public fun compareAndSet(expect: T, update: T): Boolean }

从这两条性质出发,就能得到以下内容:

  • 任意的冷流都可以通过stateIn函数转换为StateFlow热流。
  • “可观察”这一特性,使其能够完成某些仅靠状态做不到的事情,比如用户输入内容时自动保存。
  • 既然是特化版本,那么除了SharedFlow已有的特性以外,StateFlow具体特化的内容如下:
    1. StateFlow自带去重。在向订阅者发送数据时,如果新的状态和旧的状态不一致时才发送数据。
    2. 等效自带一个conflate函数,使得即使多个订阅者的消费速度不同,所有订阅者都能拿到最新的数据。
    3. StateFlow必须有一个初始化值,并且其重放数为1,缓存数为0。
    4. 基于以上结论,对于StateFlow来说,flowOnconflatebuffer(传值RENDEZVOUS或者CONFLATED)、cancellabledistinctUntilChanged函数都是无效果的。

使用示例:

kotlin
class LatestNewsViewModel( private val newsRepository: NewsRepository ) : ViewModel() { // Backing property to avoid state updates from other classes private val _uiState = MutableStateFlow(LatestNewsUiState.Success(emptyList())) // The UI collects from this StateFlow to get its state updates val uiState: StateFlow<LatestNewsUiState> = _uiState init { viewModelScope.launch { newsRepository.favoriteLatestNews // Update View with the latest favorite news // Writes to the value property of MutableStateFlow, // adding a new element to the flow and updating all // of its collectors .collect { favoriteNews -> _uiState.value = LatestNewsUiState.Success(favoriteNews) } } } } // Represents different states for the LatestNews screen sealed class LatestNewsUiState { data class Success(val news: List<ArticleHeadline>): LatestNewsUiState() data class Error(val exception: Throwable): LatestNewsUiState() } class LatestNewsActivity : AppCompatActivity() { private val latestNewsViewModel = // getViewModel() override fun onCreate(savedInstanceState: Bundle?) { ... // Start a coroutine in the lifecycle scope lifecycleScope.launch { // repeatOnLifecycle launches the block in a new coroutine every time the // lifecycle is in the STARTED state (or above) and cancels it when it's STOPPED. repeatOnLifecycle(Lifecycle.State.STARTED) { // Trigger the flow and start listening for values. // Note that this happens when lifecycle is STARTED and stops // collecting when the lifecycle is STOPPED latestNewsViewModel.uiState.collect { uiState -> // New value received when (uiState) { is LatestNewsUiState.Success -> showFavoriteNews(uiState.news) is LatestNewsUiState.Error -> showError(uiState.exception) } } } } } }

14.3 这俩如何选择

  1. 真的需要在特定的时间、位置获取Flow的最新状态吗?如果不需要,那考虑SharedFlow,比如常用的事件通知功能。
  2. 我需要重复发射和收集同样的值吗?如果需要,那考虑SharedFlow,因为StateFlow会忽略连续两次重复的值。
  3. 当有新的订阅者订阅的时候,我需要发射最近的多个值吗?如果需要,那考虑SharedFlow,可以配置replay参数。

15 MIME类型速查

json
{ ".load": "text/html", ".123": "application/vnd.lotus-1-2-3", ".3ds": "image/x-3ds", ".3g2": "video/3gpp", ".3ga": "video/3gpp", ".3gp": "video/3gpp", ".3gpp": "video/3gpp", ".602": "application/x-t602", ".669": "audio/x-mod", ".7z": "application/x-7z-compressed", ".a": "application/x-archive", ".aac": "audio/mp4", ".abw": "application/x-abiword", ".abw.crashed": "application/x-abiword", ".abw.gz": "application/x-abiword", ".ac3": "audio/ac3", ".ace": "application/x-ace", ".adb": "text/x-adasrc", ".ads": "text/x-adasrc", ".afm": "application/x-font-afm", ".ag": "image/x-applix-graphics", ".ai": "application/illustrator", ".aif": "audio/x-aiff", ".aifc": "audio/x-aiff", ".aiff": "audio/x-aiff", ".al": "application/x-perl", ".alz": "application/x-alz", ".amr": "audio/amr", ".ani": "application/x-navi-animation", ".anim[1-9j]": "video/x-anim", ".anx": "application/annodex", ".ape": "audio/x-ape", ".arj": "application/x-arj", ".arw": "image/x-sony-arw", ".as": "application/x-applix-spreadsheet", ".asc": "text/plain", ".asf": "video/x-ms-asf", ".asp": "application/x-asp", ".ass": "text/x-ssa", ".asx": "audio/x-ms-asx", ".atom": "application/atom+xml", ".au": "audio/basic", ".avi": "video/x-msvideo", ".aw": "application/x-applix-word", ".awb": "audio/amr-wb", ".awk": "application/x-awk", ".axa": "audio/annodex", ".axv": "video/annodex", ".bak": "application/x-trash", ".bcpio": "application/x-bcpio", ".bdf": "application/x-font-bdf", ".bib": "text/x-bibtex", ".bin": "application/octet-stream", ".blend": "application/x-blender", ".blender": "application/x-blender", ".bmp": "image/bmp", ".bz": "application/x-bzip", ".bz2": "application/x-bzip", ".c": "text/x-csrc", ".c++": "text/x-c++src", ".cab": "application/vnd.ms-cab-compressed", ".cb7": "application/x-cb7", ".cbr": "application/x-cbr", ".cbt": "application/x-cbt", ".cbz": "application/x-cbz", ".cc": "text/x-c++src", ".cdf": "application/x-netcdf", ".cdr": "application/vnd.corel-draw", ".cer": "application/x-x509-ca-cert", ".cert": "application/x-x509-ca-cert", ".cgm": "image/cgm", ".chm": "application/x-chm", ".chrt": "application/x-kchart", ".class": "application/x-java", ".cls": "text/x-tex", ".cmake": "text/x-cmake", ".cpio": "application/x-cpio", ".cpio.gz": "application/x-cpio-compressed", ".cpp": "text/x-c++src", ".cr2": "image/x-canon-cr2", ".crt": "application/x-x509-ca-cert", ".crw": "image/x-canon-crw", ".cs": "text/x-csharp", ".csh": "application/x-csh", ".css": "text/css", ".cssl": "text/css", ".csv": "text/csv", ".cue": "application/x-cue", ".cur": "image/x-win-bitmap", ".cxx": "text/x-c++src", ".d": "text/x-dsrc", ".dar": "application/x-dar", ".dbf": "application/x-dbf", ".dc": "application/x-dc-rom", ".dcl": "text/x-dcl", ".dcm": "application/dicom", ".dcr": "image/x-kodak-dcr", ".dds": "image/x-dds", ".deb": "application/x-deb", ".der": "application/x-x509-ca-cert", ".desktop": "application/x-desktop", ".dia": "application/x-dia-diagram", ".diff": "text/x-patch", ".divx": "video/x-msvideo", ".djv": "image/vnd.djvu", ".djvu": "image/vnd.djvu", ".dng": "image/x-adobe-dng", ".doc": "application/msword", ".docbook": "application/docbook+xml", ".docm": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".dot": "text/vnd.graphviz", ".dsl": "text/x-dsl", ".dtd": "application/xml-dtd", ".dtx": "text/x-tex", ".dv": "video/dv", ".dvi": "application/x-dvi", ".dvi.bz2": "application/x-bzdvi", ".dvi.gz": "application/x-gzdvi", ".dwg": "image/vnd.dwg", ".dxf": "image/vnd.dxf", ".e": "text/x-eiffel", ".egon": "application/x-egon", ".eif": "text/x-eiffel", ".el": "text/x-emacs-lisp", ".emf": "image/x-emf", ".emp": "application/vnd.emusic-emusic_package", ".ent": "application/xml-external-parsed-entity", ".eps": "image/x-eps", ".eps.bz2": "image/x-bzeps", ".eps.gz": "image/x-gzeps", ".epsf": "image/x-eps", ".epsf.bz2": "image/x-bzeps", ".epsf.gz": "image/x-gzeps", ".epsi": "image/x-eps", ".epsi.bz2": "image/x-bzeps", ".epsi.gz": "image/x-gzeps", ".epub": "application/epub+zip", ".erl": "text/x-erlang", ".es": "application/ecmascript", ".etheme": "application/x-e-theme", ".etx": "text/x-setext", ".exe": "application/x-ms-dos-executable", ".exr": "image/x-exr", ".ez": "application/andrew-inset", ".f": "text/x-fortran", ".f90": "text/x-fortran", ".f95": "text/x-fortran", ".fb2": "application/x-fictionbook+xml", ".fig": "image/x-xfig", ".fits": "image/fits", ".fl": "application/x-fluid", ".flac": "audio/x-flac", ".flc": "video/x-flic", ".fli": "video/x-flic", ".flv": "video/x-flv", ".flw": "application/x-kivio", ".fo": "text/x-xslfo", ".for": "text/x-fortran", ".g3": "image/fax-g3", ".gb": "application/x-gameboy-rom", ".gba": "application/x-gba-rom", ".gcrd": "text/directory", ".ged": "application/x-gedcom", ".gedcom": "application/x-gedcom", ".gen": "application/x-genesis-rom", ".gf": "application/x-tex-gf", ".gg": "application/x-sms-rom", ".gif": "image/gif", ".glade": "application/x-glade", ".gmo": "application/x-gettext-translation", ".gnc": "application/x-gnucash", ".gnd": "application/gnunet-directory", ".gnucash": "application/x-gnucash", ".gnumeric": "application/x-gnumeric", ".gnuplot": "application/x-gnuplot", ".gp": "application/x-gnuplot", ".gpg": "application/pgp-encrypted", ".gplt": "application/x-gnuplot", ".gra": "application/x-graphite", ".gsf": "application/x-font-type1", ".gsm": "audio/x-gsm", ".gtar": "application/x-tar", ".gv": "text/vnd.graphviz", ".gvp": "text/x-google-video-pointer", ".gz": "application/x-gzip", ".h": "text/x-chdr", ".h++": "text/x-c++hdr", ".hdf": "application/x-hdf", ".hh": "text/x-c++hdr", ".hp": "text/x-c++hdr", ".hpgl": "application/vnd.hp-hpgl", ".hpp": "text/x-c++hdr", ".hs": "text/x-haskell", ".htm": "text/html", ".html": "text/html", ".hwp": "application/x-hwp", ".hwt": "application/x-hwt", ".hxx": "text/x-c++hdr", ".ica": "application/x-ica", ".icb": "image/x-tga", ".icns": "image/x-icns", ".ico": "image/vnd.microsoft.icon", ".ics": "text/calendar", ".idl": "text/x-idl", ".ief": "image/ief", ".iff": "image/x-iff", ".ilbm": "image/x-ilbm", ".ime": "text/x-imelody", ".imy": "text/x-imelody", ".ins": "text/x-tex", ".iptables": "text/x-iptables", ".iso": "application/x-cd-image", ".iso9660": "application/x-cd-image", ".it": "audio/x-it", ".j2k": "image/jp2", ".jad": "text/vnd.sun.j2me.app-descriptor", ".jar": "application/x-java-archive", ".java": "text/x-java", ".jng": "image/x-jng", ".jnlp": "application/x-java-jnlp-file", ".jp2": "image/jp2", ".jpc": "image/jp2", ".jpe": "image/jpeg", ".jpeg": "image/jpeg", ".jpf": "image/jp2", ".jpg": "image/jpeg", ".jpr": "application/x-jbuilder-project", ".jpx": "image/jp2", ".js": "application/javascript", ".json": "application/json", ".jsonp": "application/jsonp", ".k25": "image/x-kodak-k25", ".kar": "audio/midi", ".karbon": "application/x-karbon", ".kdc": "image/x-kodak-kdc", ".kdelnk": "application/x-desktop", ".kexi": "application/x-kexiproject-sqlite3", ".kexic": "application/x-kexi-connectiondata", ".kexis": "application/x-kexiproject-shortcut", ".kfo": "application/x-kformula", ".kil": "application/x-killustrator", ".kino": "application/smil", ".kml": "application/vnd.google-earth.kml+xml", ".kmz": "application/vnd.google-earth.kmz", ".kon": "application/x-kontour", ".kpm": "application/x-kpovmodeler", ".kpr": "application/x-kpresenter", ".kpt": "application/x-kpresenter", ".kra": "application/x-krita", ".ksp": "application/x-kspread", ".kud": "application/x-kugar", ".kwd": "application/x-kword", ".kwt": "application/x-kword", ".la": "application/x-shared-library-la", ".latex": "text/x-tex", ".ldif": "text/x-ldif", ".lha": "application/x-lha", ".lhs": "text/x-literate-haskell", ".lhz": "application/x-lhz", ".log": "text/x-log", ".ltx": "text/x-tex", ".lua": "text/x-lua", ".lwo": "image/x-lwo", ".lwob": "image/x-lwo", ".lws": "image/x-lws", ".ly": "text/x-lilypond", ".lyx": "application/x-lyx", ".lz": "application/x-lzip", ".lzh": "application/x-lha", ".lzma": "application/x-lzma", ".lzo": "application/x-lzop", ".m": "text/x-matlab", ".m15": "audio/x-mod", ".m2t": "video/mpeg", ".m3u": "audio/x-mpegurl", ".m3u8": "audio/x-mpegurl", ".m4": "application/x-m4", ".m4a": "audio/mp4", ".m4b": "audio/x-m4b", ".m4v": "video/mp4", ".mab": "application/x-markaby", ".man": "application/x-troff-man", ".mbox": "application/mbox", ".md": "application/x-genesis-rom", ".mdb": "application/vnd.ms-access", ".mdi": "image/vnd.ms-modi", ".me": "text/x-troff-me", ".med": "audio/x-mod", ".metalink": "application/metalink+xml", ".mgp": "application/x-magicpoint", ".mid": "audio/midi", ".midi": "audio/midi", ".mif": "application/x-mif", ".minipsf": "audio/x-minipsf", ".mka": "audio/x-matroska", ".mkv": "video/x-matroska", ".ml": "text/x-ocaml", ".mli": "text/x-ocaml", ".mm": "text/x-troff-mm", ".mmf": "application/x-smaf", ".mml": "text/mathml", ".mng": "video/x-mng", ".mo": "application/x-gettext-translation", ".mo3": "audio/x-mo3", ".moc": "text/x-moc", ".mod": "audio/x-mod", ".mof": "text/x-mof", ".moov": "video/quicktime", ".mov": "video/quicktime", ".movie": "video/x-sgi-movie", ".mp+": "audio/x-musepack", ".mp2": "video/mpeg", ".mp3": "audio/mpeg", ".mp4": "video/mp4", ".mpc": "audio/x-musepack", ".mpe": "video/mpeg", ".mpeg": "video/mpeg", ".mpg": "video/mpeg", ".mpga": "audio/mpeg", ".mpp": "audio/x-musepack", ".mrl": "text/x-mrml", ".mrml": "text/x-mrml", ".mrw": "image/x-minolta-mrw", ".ms": "text/x-troff-ms", ".msi": "application/x-msi", ".msod": "image/x-msod", ".msx": "application/x-msx-rom", ".mtm": "audio/x-mod", ".mup": "text/x-mup", ".mxf": "application/mxf", ".n64": "application/x-n64-rom", ".nb": "application/mathematica", ".nc": "application/x-netcdf", ".nds": "application/x-nintendo-ds-rom", ".nef": "image/x-nikon-nef", ".nes": "application/x-nes-rom", ".nfo": "text/x-nfo", ".not": "text/x-mup", ".nsc": "application/x-netshow-channel", ".nsv": "video/x-nsv", ".o": "application/x-object", ".obj": "application/x-tgif", ".ocl": "text/x-ocl", ".oda": "application/oda", ".odb": "application/vnd.oasis.opendocument.database", ".odc": "application/vnd.oasis.opendocument.chart", ".odf": "application/vnd.oasis.opendocument.formula", ".odg": "application/vnd.oasis.opendocument.graphics", ".odi": "application/vnd.oasis.opendocument.image", ".odm": "application/vnd.oasis.opendocument.text-master", ".odp": "application/vnd.oasis.opendocument.presentation", ".ods": "application/vnd.oasis.opendocument.spreadsheet", ".odt": "application/vnd.oasis.opendocument.text", ".oga": "audio/ogg", ".ogg": "video/x-theora+ogg", ".ogm": "video/x-ogm+ogg", ".ogv": "video/ogg", ".ogx": "application/ogg", ".old": "application/x-trash", ".oleo": "application/x-oleo", ".opml": "text/x-opml+xml", ".ora": "image/openraster", ".orf": "image/x-olympus-orf", ".otc": "application/vnd.oasis.opendocument.chart-template", ".otf": "application/x-font-otf", ".otg": "application/vnd.oasis.opendocument.graphics-template", ".oth": "application/vnd.oasis.opendocument.text-web", ".otp": "application/vnd.oasis.opendocument.presentation-template", ".ots": "application/vnd.oasis.opendocument.spreadsheet-template", ".ott": "application/vnd.oasis.opendocument.text-template", ".owl": "application/rdf+xml", ".oxt": "application/vnd.openofficeorg.extension", ".p": "text/x-pascal", ".p10": "application/pkcs10", ".p12": "application/x-pkcs12", ".p7b": "application/x-pkcs7-certificates", ".p7s": "application/pkcs7-signature", ".pack": "application/x-java-pack200", ".pak": "application/x-pak", ".par2": "application/x-par2", ".pas": "text/x-pascal", ".patch": "text/x-patch", ".pbm": "image/x-portable-bitmap", ".pcd": "image/x-photo-cd", ".pcf": "application/x-cisco-vpn-settings", ".pcf.gz": "application/x-font-pcf", ".pcf.z": "application/x-font-pcf", ".pcl": "application/vnd.hp-pcl", ".pcx": "image/x-pcx", ".pdb": "chemical/x-pdb", ".pdc": "application/x-aportisdoc", ".pdf": "application/pdf", ".pdf.bz2": "application/x-bzpdf", ".pdf.gz": "application/x-gzpdf", ".pef": "image/x-pentax-pef", ".pem": "application/x-x509-ca-cert", ".perl": "application/x-perl", ".pfa": "application/x-font-type1", ".pfb": "application/x-font-type1", ".pfx": "application/x-pkcs12", ".pgm": "image/x-portable-graymap", ".pgn": "application/x-chess-pgn", ".pgp": "application/pgp-encrypted", ".php": "application/x-php", ".php3": "application/x-php", ".php4": "application/x-php", ".pict": "image/x-pict", ".pict1": "image/x-pict", ".pict2": "image/x-pict", ".pickle": "application/python-pickle", ".pk": "application/x-tex-pk", ".pkipath": "application/pkix-pkipath", ".pkr": "application/pgp-keys", ".pl": "application/x-perl", ".pla": "audio/x-iriver-pla", ".pln": "application/x-planperfect", ".pls": "audio/x-scpls", ".pm": "application/x-perl", ".png": "image/png", ".pnm": "image/x-portable-anymap", ".pntg": "image/x-macpaint", ".po": "text/x-gettext-translation", ".por": "application/x-spss-por", ".pot": "text/x-gettext-translation-template", ".ppm": "image/x-portable-pixmap", ".pps": "application/vnd.ms-powerpoint", ".ppt": "application/vnd.ms-powerpoint", ".pptm": "application/vnd.openxmlformats-officedocument.presentationml.presentation", ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", ".ppz": "application/vnd.ms-powerpoint", ".prc": "application/x-palm-database", ".ps": "application/postscript", ".ps.bz2": "application/x-bzpostscript", ".ps.gz": "application/x-gzpostscript", ".psd": "image/vnd.adobe.photoshop", ".psf": "audio/x-psf", ".psf.gz": "application/x-gz-font-linux-psf", ".psflib": "audio/x-psflib", ".psid": "audio/prs.sid", ".psw": "application/x-pocket-word", ".pw": "application/x-pw", ".py": "text/x-python", ".pyc": "application/x-python-bytecode", ".pyo": "application/x-python-bytecode", ".qif": "image/x-quicktime", ".qt": "video/quicktime", ".qtif": "image/x-quicktime", ".qtl": "application/x-quicktime-media-link", ".qtvr": "video/quicktime", ".ra": "audio/vnd.rn-realaudio", ".raf": "image/x-fuji-raf", ".ram": "application/ram", ".rar": "application/x-rar", ".ras": "image/x-cmu-raster", ".raw": "image/x-panasonic-raw", ".rax": "audio/vnd.rn-realaudio", ".rb": "application/x-ruby", ".rdf": "application/rdf+xml", ".rdfs": "application/rdf+xml", ".reg": "text/x-ms-regedit", ".rej": "application/x-reject", ".rgb": "image/x-rgb", ".rle": "image/rle", ".rm": "application/vnd.rn-realmedia", ".rmj": "application/vnd.rn-realmedia", ".rmm": "application/vnd.rn-realmedia", ".rms": "application/vnd.rn-realmedia", ".rmvb": "application/vnd.rn-realmedia", ".rmx": "application/vnd.rn-realmedia", ".roff": "text/troff", ".rp": "image/vnd.rn-realpix", ".rpm": "application/x-rpm", ".rss": "application/rss+xml", ".rt": "text/vnd.rn-realtext", ".rtf": "application/rtf", ".rtx": "text/richtext", ".rv": "video/vnd.rn-realvideo", ".rvx": "video/vnd.rn-realvideo", ".s3m": "audio/x-s3m", ".sam": "application/x-amipro", ".sami": "application/x-sami", ".sav": "application/x-spss-sav", ".scm": "text/x-scheme", ".sda": "application/vnd.stardivision.draw", ".sdc": "application/vnd.stardivision.calc", ".sdd": "application/vnd.stardivision.impress", ".sdp": "application/sdp", ".sds": "application/vnd.stardivision.chart", ".sdw": "application/vnd.stardivision.writer", ".sgf": "application/x-go-sgf", ".sgi": "image/x-sgi", ".sgl": "application/vnd.stardivision.writer", ".sgm": "text/sgml", ".sgml": "text/sgml", ".sh": "application/x-shellscript", ".shar": "application/x-shar", ".shn": "application/x-shorten", ".siag": "application/x-siag", ".sid": "audio/prs.sid", ".sik": "application/x-trash", ".sis": "application/vnd.symbian.install", ".sisx": "x-epoc/x-sisx-app", ".sit": "application/x-stuffit", ".siv": "application/sieve", ".sk": "image/x-skencil", ".sk1": "image/x-skencil", ".skr": "application/pgp-keys", ".slk": "text/spreadsheet", ".smaf": "application/x-smaf", ".smc": "application/x-snes-rom", ".smd": "application/vnd.stardivision.mail", ".smf": "application/vnd.stardivision.math", ".smi": "application/x-sami", ".smil": "application/smil", ".sml": "application/smil", ".sms": "application/x-sms-rom", ".snd": "audio/basic", ".so": "application/x-sharedlib", ".spc": "application/x-pkcs7-certificates", ".spd": "application/x-font-speedo", ".spec": "text/x-rpm-spec", ".spl": "application/x-shockwave-flash", ".spx": "audio/x-speex", ".sql": "text/x-sql", ".sr2": "image/x-sony-sr2", ".src": "application/x-wais-source", ".srf": "image/x-sony-srf", ".srt": "application/x-subrip", ".ssa": "text/x-ssa", ".stc": "application/vnd.sun.xml.calc.template", ".std": "application/vnd.sun.xml.draw.template", ".sti": "application/vnd.sun.xml.impress.template", ".stm": "audio/x-stm", ".stw": "application/vnd.sun.xml.writer.template", ".sty": "text/x-tex", ".sub": "text/x-subviewer", ".sun": "image/x-sun-raster", ".sv4cpio": "application/x-sv4cpio", ".sv4crc": "application/x-sv4crc", ".svg": "image/svg+xml", ".svgz": "image/svg+xml-compressed", ".swf": "application/x-shockwave-flash", ".sxc": "application/vnd.sun.xml.calc", ".sxd": "application/vnd.sun.xml.draw", ".sxg": "application/vnd.sun.xml.writer.global", ".sxi": "application/vnd.sun.xml.impress", ".sxm": "application/vnd.sun.xml.math", ".sxw": "application/vnd.sun.xml.writer", ".sylk": "text/spreadsheet", ".t": "text/troff", ".t2t": "text/x-txt2tags", ".tar": "application/x-tar", ".tar.bz": "application/x-bzip-compressed-tar", ".tar.bz2": "application/x-bzip-compressed-tar", ".tar.gz": "application/x-compressed-tar", ".tar.lzma": "application/x-lzma-compressed-tar", ".tar.lzo": "application/x-tzo", ".tar.xz": "application/x-xz-compressed-tar", ".tar.z": "application/x-tarz", ".tbz": "application/x-bzip-compressed-tar", ".tbz2": "application/x-bzip-compressed-tar", ".tcl": "text/x-tcl", ".tex": "text/x-tex", ".texi": "text/x-texinfo", ".texinfo": "text/x-texinfo", ".tga": "image/x-tga", ".tgz": "application/x-compressed-tar", ".theme": "application/x-theme", ".themepack": "application/x-windows-themepack", ".tif": "image/tiff", ".tiff": "image/tiff", ".tk": "text/x-tcl", ".tlz": "application/x-lzma-compressed-tar", ".tnef": "application/vnd.ms-tnef", ".tnf": "application/vnd.ms-tnef", ".toc": "application/x-cdrdao-toc", ".torrent": "application/x-bittorrent", ".tpic": "image/x-tga", ".tr": "text/troff", ".ts": "application/x-linguist", ".tsv": "text/tab-separated-values", ".tta": "audio/x-tta", ".ttc": "application/x-font-ttf", ".ttf": "application/x-font-ttf", ".ttx": "application/x-font-ttx", ".txt": "text/plain", ".txz": "application/x-xz-compressed-tar", ".tzo": "application/x-tzo", ".ufraw": "application/x-ufraw", ".ui": "application/x-designer", ".uil": "text/x-uil", ".ult": "audio/x-mod", ".uni": "audio/x-mod", ".uri": "text/x-uri", ".url": "text/x-uri", ".ustar": "application/x-ustar", ".vala": "text/x-vala", ".vapi": "text/x-vala", ".vcf": "text/directory", ".vcs": "text/calendar", ".vct": "text/directory", ".vda": "image/x-tga", ".vhd": "text/x-vhdl", ".vhdl": "text/x-vhdl", ".viv": "video/vivo", ".vivo": "video/vivo", ".vlc": "audio/x-mpegurl", ".vob": "video/mpeg", ".voc": "audio/x-voc", ".vor": "application/vnd.stardivision.writer", ".vst": "image/x-tga", ".wav": "audio/x-wav", ".wax": "audio/x-ms-asx", ".wb1": "application/x-quattropro", ".wb2": "application/x-quattropro", ".wb3": "application/x-quattropro", ".wbmp": "image/vnd.wap.wbmp", ".wcm": "application/vnd.ms-works", ".wdb": "application/vnd.ms-works", ".webm": "video/webm", ".wk1": "application/vnd.lotus-1-2-3", ".wk3": "application/vnd.lotus-1-2-3", ".wk4": "application/vnd.lotus-1-2-3", ".wks": "application/vnd.ms-works", ".wma": "audio/x-ms-wma", ".wmf": "image/x-wmf", ".wml": "text/vnd.wap.wml", ".wmls": "text/vnd.wap.wmlscript", ".wmv": "video/x-ms-wmv", ".wmx": "audio/x-ms-asx", ".wp": "application/vnd.wordperfect", ".wp4": "application/vnd.wordperfect", ".wp5": "application/vnd.wordperfect", ".wp6": "application/vnd.wordperfect", ".wpd": "application/vnd.wordperfect", ".wpg": "application/x-wpg", ".wpl": "application/vnd.ms-wpl", ".wpp": "application/vnd.wordperfect", ".wps": "application/vnd.ms-works", ".wri": "application/x-mswrite", ".wrl": "model/vrml", ".wv": "audio/x-wavpack", ".wvc": "audio/x-wavpack-correction", ".wvp": "audio/x-wavpack", ".wvx": "audio/x-ms-asx", ".x3f": "image/x-sigma-x3f", ".xac": "application/x-gnucash", ".xbel": "application/x-xbel", ".xbl": "application/xml", ".xbm": "image/x-xbitmap", ".xcf": "image/x-xcf", ".xcf.bz2": "image/x-compressed-xcf", ".xcf.gz": "image/x-compressed-xcf", ".xhtml": "application/xhtml+xml", ".xi": "audio/x-xi", ".xla": "application/vnd.ms-excel", ".xlc": "application/vnd.ms-excel", ".xld": "application/vnd.ms-excel", ".xlf": "application/x-xliff", ".xliff": "application/x-xliff", ".xll": "application/vnd.ms-excel", ".xlm": "application/vnd.ms-excel", ".xls": "application/vnd.ms-excel", ".xlsm": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlt": "application/vnd.ms-excel", ".xlw": "application/vnd.ms-excel", ".xm": "audio/x-xm", ".xmf": "audio/x-xmf", ".xmi": "text/x-xmi", ".xml": "application/xml", ".xpm": "image/x-xpixmap", ".xps": "application/vnd.ms-xpsdocument", ".xsl": "application/xml", ".xslfo": "text/x-xslfo", ".xslt": "application/xml", ".xspf": "application/xspf+xml", ".xul": "application/vnd.mozilla.xul+xml", ".xwd": "image/x-xwindowdump", ".xyz": "chemical/x-pdb", ".xz": "application/x-xz", ".w2p": "application/w2p", ".z": "application/x-compress", ".zabw": "application/x-abiword", ".zip": "application/zip" }

本文作者:御坂19327号

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!