主要内容为设计、完成 Task 模块的过程中,遇到的问题和值得记录的一些点。
另外,Android 版本代号速查也在这里。
真是好久没写了,好多问题攒下来之后再回看都想不起来了,再不写估计全忘光了。
版本 | API 级别 | VERSION_CODE |
---|---|---|
Android 16 | 暂无 | 暂无 |
Android 15 | 35 | VANILLA_ICE_CREAM |
Android 14 | 34 | UPSIDE_DOWN_CAKE |
Android 13 | 33 | TIRAMISU |
Android 12 L | 32 | S_V2 |
Android 12 | 31 | S |
Android 11 | 30 | R |
Android 10 | 29 | Q |
Android 9 | 28 | P |
Android 8.1 | 27 | O_MR1 |
Android 8 | 26 | O |
Android 7.1 | 25 | N_MR1 |
Android 7 | 24 | N |
Android 6 | 23 | M |
Android 5.1 | 22 | LOLLIPOP_MR1 |
Android 5 | 21 | L(LOLLIPOP) |
Android 4.4W | 20 | KITKAT_WATCH |
Android 4 | 19 | KITKAT |
在写 Task 模块的时候,有一个需求是给任务选分类,包括也得给提醒选择提醒模式,都要用到这种下拉菜单栏,所以开写。完成之后是这样的:
这里就是搭配OutlinedTextField
和ExposedDropdownMenuBox
两个组件完成。把编辑框设计成只读,并且回调设成空函数即可。
kotlininterface 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")
}
}
)
}
}
}
}
menuAnchor
修饰符一个要记的是这个修饰符,修饰给OutlinedTextField
用的。ExposedDropdownMenuBox
组件会提供一个ExposedDropdownMenuBoxScope
,在这个作用域内部的组件可以调用该修饰符函数。
kotlinpublic abstract fun Modifier.menuAnchor(
type: MenuAnchorType,
enabled: Boolean = true
): Modifier
该修饰符函数的作用是标明处于作用域内部的这个组件的作用,以及是否响应对下拉菜单的控制。前者通过type
参数控制,后者通过enabled
参数控制。注意enabled
参数只控制该组件是否影响下拉菜单,并不控制该组件本身的enabled
功能(比如TextField
,比如IconButton
)。
MenuAnchorType
共有三种:PrimaryNotEditable
,PrimaryEditable
,SecondaryEditable
。
PrimaryNotEditable
:主锚点,配合只读的输入框,点击该组件会展开下拉菜单,并且焦点在下拉菜单上。PrimaryEditable
:主锚点,但是允许用户输入。点击该组件也会展开下拉菜单,但是键盘也会出现,并且焦点在键盘上。SecondaryEditable
:辅助锚点,可以给输入框里面的图标用,可以与主锚点并存。点击该组件也会展开下拉菜单,取决于是否有辅助服务(?),焦点将在下拉菜单或者键盘上。一般情况下,主锚点够用了。
之所以有这个问题,一来是因为这个组件和viewModel
数据初始化有一些冲突(之前的写法会有,现在没有了)。二来是因为完成这个模块以来的一些模糊的想法:既然某些状态一定会提交给ViewModel
,那么组件内部还需要再维护自己的状态吗?
之前这个组件是这样的,传入一个initialData
,根据这个参数初始化并且缓存该组件内部的一个选择索引状态和要显示的文字状态。这么写在data
参数固定不变的时候是没问题的,但是一旦data
参数依赖一个流,就会出现问题,即初始化状态错误,并且在重组的时候因为有缓存,这个错误没有得到修正。
假设data
参数来源于这里:
kotlinval categories = viewModel.categoriesFlow.collectAsStateWithLifecycle(initialValue = listOf())
那么运行的顺序是这样的:
collectAsStateWithLifecycle
函数的data
拿到数据,是一个空列表。data
发生改变,开始重组。但是因为内部的状态已经被缓存,因为重组无法再修正错误的状态。其实这个问题不光data
参数有,原来的value
参数也有。只是这个参数是从ViewModel
里来的,我可以通过ViewModel
内部的类似isLoading
这样的状态去控制这个组件开始绘制的时间。但是data
参数不行,流什么时候准备好是我完全不能控制的。对于这种List
类型的数据,我也没法通过发空列表来表示此时还未准备好,毕竟空列表也真的可以有意义。
既然控制组件绘制时间这边行不通,那么组件内部是否可以修改?这也就提到了之前的那个问题:状态应该放在哪? 组件内部可以缓存的状态可以有多少?哪些应该提到父组件里?哪些应该提到ViewModel
里?
这个问题的答案其实是从官方的组件用法中来的。比如说下面这个经典用法:
kotlinvar testState by remember { mutableStateOf("") }
OutlinedTextField(
value = testState,
onValueChange = {
testState = it
}
)
一个OutlinedTextField
组件内部需要维护的状态肯定不止一个字符串,比如说被点击与否直接决定label
的位置,这就值得维护一个状态。很显然,对于这两种不同的状态,字符串选择上提给父组件,而label
位置这种就选择内部维护。(嘛,翻了源码之后我肯定是知道其实OutlinedTextField
内部的BasicTextField
内部也维护一个字符串状态来的,只是说这个意思)
从这个示例里,算是总结(?)了几条用法:
ViewModel
里最好(当然得考虑页面和ViewModel
生命周期不同所带来的可能的影响)。手势检测有两种修饰符可以实现,一个比较简单易用,另一个比较复杂,功能更多。
combinedClickable
,简单易用版的,只能支持单击,双击,长按combinedClickable
有两种参数列表,但是都比较简单易用。
kotlinpublic fun Modifier.combinedClickable(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onLongClickLabel: String? = null,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
onClick: () -> Unit
): Modifier
kotlinpublic 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
覆盖点击事件中,就用到这个东西来共享FilterChip
和Box
的交互状态,从而让FliterChip
能对Box
的交互事件作出响应。indication
参数用于指定组件的交互指示。用人话举例来说,就是用户点击之后组件的那个反馈(比如说波纹动画),就是这个参数。pointerInput
,比较复杂,但是支持单击双击长按拖拽等等kotlinpublic fun Modifier.pointerInput(
key1: Any?,
block: suspend PointerInputScope.() -> Unit
): Modifier
这个修饰符也有三种参数列表,但是区别都不大,只是在key
上有数量的区别,在此就不表了。这个key
参数类似其他的什么LaunchedEffect
函数的key
参数,作用都是一样的,在key
参数发生改变时重新刷新block
内部的逻辑。
提示
可能比较抽象的是,这些回调某种意义上也要算是一种“状态”,因为这些回调可以持有对外部变量的引用(可变变量)/值(不可变变量)。
想象这么一种情况:一个LazyColumn
,pointerInput
内部使用了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
是用户点击位置相对于父组件的位置):
kotlinpublic suspend fun PointerInputScope.detectTapGestures(
onDoubleTap: ((Offset) -> Unit)? = null,
onLongPress: ((Offset) -> Unit)? = null,
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
onTap: ((Offset) -> Unit)? = null
): ERROR
拖拽类的手势去找detectDragGestures
,detectHorizontalDragGestures
和detectVerticalDragGestures
这仨,下面也会有用法,这里不再多表。剩下的两个扩展函数,一个是处理双指手势的(旋转,放大什么的),另一个是长按后再拖拽。
FilterChip
覆盖点击事件问题:FilterChip
加了类似pointerInput
这种修饰符之后,无法检测长按事件。
答案:Chip
组件内部是基于Surface
组件的,后者实现了内部的clickable
修饰器。
答案来源:android - Detecting long press events on Chips in Jetpack Compose - Stack Overflow
解决方案:用一个Box
先把FliterChip
和一个无子组件的Box
包在一起,并且内部的无组件Box
尺寸和父Box
一致,之后内部的FliterChip
和Box
共享一个MutableInteractionSource
,把长按检测加在内部的无组件Box
上。
kotlinval 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
}
)
)
}
DisposableEffect
用法kotlinpublic fun DisposableEffect(
key1: Any?,
effect: DisposableEffectScope.() -> DisposableEffectResult
): Unit
这个东西和LaunchedEffect
差不多,但是它不会默认启动协程,同时要多一个功能,就是DisposableEffectScope
的扩展函数onDispose
。这个东西会在该组件被销毁时执行,可以帮助清理页面的各种资源一类的东西,比如下面的用法:
kotlinfun <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()
}
}
}
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)
}
}
}
}
下面就挑几个点说一下:
detectHorizontalDragGestures
用法这个扩展函数是用于检测水平拖拽动作的,上参数表:
kotlinpublic suspend fun PointerInputScope.detectHorizontalDragGestures(
onDragStart: (Offset) -> Unit = { },
onDragEnd: () -> Unit = { },
onDragCancel: () -> Unit = { },
onHorizontalDrag: (PointerInputChange, Float) -> Unit
): Unit
前俩不表,第三个参数用于本次手势被别的检测并且消费之后触发;最后一个参数会在拖拽事件发生后触发,并且传入这次事件和水平方向上的拖拽距离。
这最后一个参数有点难懂,它实际上是这么理解的:当用户在屏幕上横划的时候,这整个的横划动作是归
detectHorizontalDragGestures
的前两个参数管。实际上底层并不是这样的,一个横划动作会被系统理解为多个触摸位置改变的事件(也就是PointerInputChange
这个东西)。每当用户的触摸位置改变一定距离(注释里说这个检测逻辑是根据AwaitPointerEventScope.awaitHorizontalTouchSlopOrCancellation
扩展函数,这个函数的依据来源是ViewConfiguration.touchSlop
)之后,就会被认为是一次“水平拖拽”(此拖拽是“多个触摸位置改变事件”层次上的拖拽,并不是指整个的从用户按下到松手的拖拽动作),并且立即触发onHorizontalDrag
回调。
所以按照这些参数的含义,这东西就可以这么使:
kotlinval 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) // 确定组件位置
},
) {
...
}
这个“内部逻辑设计”指的是如何处理数据,如何处理事件,如何移动列表一类的逻辑。说实话这个组件真不想写第二遍
这个组件的大体思路就是:
datalist
,nextDataGenerator
和previousDataGenerator
确定展示的数据,并且把展示的数据和组件写到items
函数里。LazyRow
组件,需要先通过userScrollEnabled
参数来控制这个组件不响应滑动事件。detectHorizontalDragGestures
这个函数拿到用户滑动的距离,并且判断是否超过阈值,是左滑还是右滑。onDragEnd
参数里,开始处理滑动结束后的事务:
nextDataGenerator
或previousDataGenerator
来生成新的列表项时,直接通过rowState
来导航到下一个/上一个列表项即可。nextDataGenerator
参数生成新的列表项(判空)加在datalist
尾部,然后导航到当前的索引+1即可。pointerInput
这个修饰符本身也需要通过指定key
的方式重新初始化(detectHorizontalDragGestures
参数捕获了外部的索引值)。但是,在onDragEnd
里启动的协程依然会运行。因此即使索引值发生变化,onDragEnd
里的逻辑也和没变化是一致的,也就是为什么代码中会导航到index
和index - 1
这两个位置,而不是index - 1
和index - 2
。可以通过Color.Transparent
来指定透明颜色,它的定义是:
kotlin@Stable
val Transparent = Color(0x00000000)
至于为什么这都需要特意写一笔,我只能说这就是谷歌的文档的可查询性。
LifecycleOwner
监控和感知生命周期鉴于在compose下的页面和Activity是完全分离的状态,那么假如我想要在页面每次开始/重启时执行一些操作来更新页面,就有两种操作:
onCreate
,onResume
等函数中直接操作viewModel
,来达到更新页面的目的。LifecycleOwner
拿到距离当前Composable函数最近的组件的生命周期。(这里的组件指的是Acitivity,Fragment这种组件,并非compose里面的显示组件)借助LifecycleOwner
监控生命周期有两个比较常用的写法:
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)
}
}
}
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)
}
}
}
}
为输入框指定键盘类型,或者修改键位的行为等等。
kotlinOutlinedTextField(
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 的行为
}
)
)
这个键盘类型和右下角按钮的行为并不止上面这一种,只是库里这些常量/函数名字都比较易懂,所以不再说明。
常用FocusManager
,FocusRequester
和onFocusChanged
修饰符来管理组件是否聚焦于某个组件上。
比如以下组件,会在用户点击键盘的某个按钮后自动清除焦点。
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 被点击时 清除全局焦点
}
)
)
}
AnimatedContent
和AnimatedVisibility
组件为组件快速应用动画AnimatedContent
和AnimatedVisibility
是能够快速为组件应用变化动画的一个组件。它们俩都能够在目标状态发生变化时自动开始动画。
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 {
...
}
}
}
}
AnimatedVisibility
某种意义上算是AnimatedContent
的一个特殊用法,即控制一个组件的显示/消失动画。
注意,该组件的消失动画结束后,内部的组件会自动退出绘制树,并且取消组合。
kotlinAnimatedVisibility(
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")
}
}
combine
combine
函数的作用是合并流,形成一个新流。只要它所依赖的流的其中一个发射了新值,那么transform
参数就会按照所有依赖的流的最新的值执行一遍,并且发射新值。
使用示例:
kotlinval taskInfoFlowAfterFilter = combine (
allTaskInfoFlow,
selectedCategoriesList,
selectedTagsList
) { taskList, categoriesList, tagsList -> // 对应三个依赖的流的最新的值
... // 处理并且发射值
}
map
对流中的每一个所发射的值,按transform
参数进行一次处理,并且返回这样得到的新流。
kotlinval taskWithDeadlineFlow = taskInfoFlowAfterFilter
.map {
it.filter {
it.task.deadLine != null && it.task.deadLine.isAfter(LocalDateTime.now())
}.groupBy { taskInfo ->
taskInfo.task.deadLine!!.toLocalDate()
}.toSortedMap()
}
onEach
在上游的流的新值向下传递之前,对每个新值应用action
参数。
注意,该操作符返回的流和原来的流类型一致。
kotlinval 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)
}
}
}
shareIn
该函数用于将一个冷流转换为热流(热流:SharedFlow<T>
),从而允许多个订阅者共享该流的数据,避免每个订阅者collect
时上游数据源都要重复操作。
提示
冷流和热流是流的两种不同形式。
冷流没有订阅者就不工作,只有在有新的订阅者时才开始收集数据然后发射出去,并且每个订阅者都有独立的数据流(这也意味着每个订阅者都需要上游重新执行一次操作),彼此互不干扰。冷流的生命周期完全取决于有无订阅者。
与冷流相对的,热流能够独立工作,能够持有独立的生命周期,不依赖订阅者的存在来维持自身存活。并且,热流的所有订阅者共享同一份数据。
该函数的三个参数作用如下:
scope: CoroutineScope
:指定该流运行在哪个协程上,如果该协程的作用域取消,则该流随之结束。started: SharingStarted
:指定该流的启动策略,有Eagerly
,Lazily
和WhileSubscribed(stopTimeoutMillis)
三种。replay: Int
:重放数量,决定每个新的订阅者能够收到多少条历史数据。kotlinval allTaskInfoFlow = taskRepository.getTasksInfoInFlow()
.shareIn(viewModelScope, SharingStarted.Lazily, replay = 1)
另外,该函数的官方文档中也有写一些常见操作,比如热流初始化值,捕获异常,指定缓存大小,等等,在此不表。
flatMapLatest
这个函数其实没用过,但是我觉得挺好玩的,而且感觉以后真说不定要用,就记一笔。
该函数用于形成一个新流(字面义),当上游的流发射一个新值时,transform
参数就会执行、返回一个新流,并且取消掉以前由tranform
形成的流。
示例:
kotlinflow {
emit("a")
delay(100)
emit("b")
}.flatMapLatest { value ->
flow {
emit(value)
delay(200)
emit(value + "_last")
}
}
预期结果:a b b_last
debounce
、distinctUntilChanged
这两个修饰符常用于从组件到 ViewModel 层的流,用于防止流短时间内发射过多的值。
debounce
:延迟一段时间后,发送最新值distinctUntilChanged
:只发送不重复的值示例:
kotlinoverride fun startSearch() {
searchJob?.cancel()
searchJob = viewModelScope.launch(Dispatchers.IO) {
keywordFlow
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { keyword ->
taskRepository.searchTask(keyword)
}
.cachedIn(viewModelScope)
.collectLatest {
resultFlow.value = it
}
}
}
这个需求就不多说了,很常见。我想要的是搜索框作为一个通用组件,其数据,搜索逻辑和组件本身是解耦的,通过同一个接口互相配合使用。因此就有了以下组件:
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>
在数据库里的搜索逻辑是没什么可说的,就嗯like就行。主要是viewModel层上的操作:
数据库内可以考虑上 fts 来进行全文搜索,比嗯like快。
kotlinoverride 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()
}
kotlinSealedSearchBar(
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()
}
}
SharedFlow
/MutableSharedFlow
和StateFlow
/MutableStateFlow
先说明,为什么 ViewModel 层不用冷流,而是使用这两个热流?在12.4中说明过冷流和热流的区别。正因为冷流对每个订阅者都有独立的数据流和单独的上游数据源的操作(收集这个动作会触发上游数据源的代码),所以对于 ViewModel 中这种可能有多个订阅者的状态流是不建议用的,太容易造成资源浪费。至于 Repository 层传上来的冷流也都建议通过shareIn
或者stateIn
函数进行转换。
SharedFlow
和StateFlow
这两个都是 ViewModel 中常用的状态流,用于数据向 View 层流动和事件向 ViewModel 层流动,在这里说明一下两者的区别。
参照官方文档:StateFlow 和 SharedFlow
SharedFlow
按官方定义,它是一个状态容器式可观察数据流(a state-holder observable flow
)。首先,它是一个热流,所以它具有热流所具备的特性:可管理生命周期,可重放,可缓存数据,多订阅者不会触发多次数据源代码。它可以通过sharedIn
函数从一个冷流转换而来
使用示例:
kotlinclass 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
需要三个参数:
kotlinpublic fun <T> MutableSharedFlow(
replay: Int = 0,
extraBufferCapacity: Int = 0,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
)
replay
:重放数,对于每个新的订阅者,会发送多少历史数据extraBufferCapacity
:缓冲区大小。onBufferOverflow
:缓存区溢出策略,顾名思义,缓存区溢出时可以选择去掉最旧/最新数据,或者挂起emit
操作。缓冲区溢出仅在至少有一个订阅者尚未准备好接收新值时才会发生。StateFlow
它有两个需要特别说明的性质:
State
,如果只是 ViewModel 和 View 层交换数据,那么State
就够用了;StateFlow
为它添加了“可观察”这个特性。SharedFlow
,详见它的定义:kotlinpublic 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
具体特化的内容如下:
StateFlow
自带去重。在向订阅者发送数据时,如果新的状态和旧的状态不一致时才发送数据。conflate
函数,使得即使多个订阅者的消费速度不同,所有订阅者都能拿到最新的数据。StateFlow
必须有一个初始化值,并且其重放数为1,缓存数为0。StateFlow
来说,flowOn
、conflate
、buffer
(传值RENDEZVOUS
或者CONFLATED
)、cancellable
和distinctUntilChanged
函数都是无效果的。使用示例:
kotlinclass 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)
}
}
}
}
}
}
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 许可协议。转载请注明出处!