2024-10-20
Kotlin & 客户端
00

目录

1 File 模块相关
2 遇到的一些问题
2.1 导入不正常
2.2 SnackBar 通知
2.4 动态获取组件大小和位置
2.5 任意组件的点击效果
2.6 动画效果
3 什么是异步?

主要内容:

  1. File 模块相关内容
  2. 遇到的一些问题
  3. 异步是什么?

1 File 模块相关

File 这个模块,先说数据库设计。一个文件,它所需要存储的有这么几项:

  1. 文件自身信息,包括文件名,文件 UUID,文件最新修改时间
  2. 文件所关联的 Tag
  3. 文件自身的版本记录,包括每个记录的说明,每个记录所对应的实际文件存储路径,何时修改

按照这个需求,就有了这个表结构:

kotlin
/** * 文件有两个东西 * 一个是 tag 多对多关系 * 一个是版本记录 一对多关系 */ @Entity( tableName = "file", indices = [Index(value = ["fileUUID"], unique = true)] // 加索引 ) data class File ( @PrimaryKey(autoGenerate = true) var fileId: Long = 0, @ColumnInfo val fileUUID: String = "", @ColumnInfo val fileName: String = "", @ColumnInfo val fileComment: String = "", @ColumnInfo val latestModifyTime: LocalDateTime = LocalDateTime.now(), @ColumnInfo val isDelete: Boolean = false, // false 为0 true 为1 ) /** * 和 Tag 的多对多关系 */ @Entity( tableName = "tag", indices = [Index(value = ["tagName"], unique = true)] ) data class Tag ( @PrimaryKey(autoGenerate = true) var tagId: Long = 0, @ColumnInfo val tagName: String = "", ) { // 这个方法重写是给显示层面的 joinToString 方法调用写的 override fun toString(): String { return tagName } } // 定义文件和 tag 的关系 @Entity( primaryKeys = ["fileUUID", "tagId"], indices = [Index(value = ["tagId"])] ) data class FileTagRef ( val fileUUID: String, val tagId: Long, ) /** * 和版本记录的一对多关系 */ @Entity(tableName = "fileVersion") data class FileVersion ( @PrimaryKey(autoGenerate = true) var fileVersionId: Long = 0, @ColumnInfo(index = true) val fileUUID: String = "", @ColumnInfo val version: Int = 0, @ColumnInfo val filePath: String = "", @ColumnInfo val modifyTime: LocalDateTime = LocalDateTime.now(), @ColumnInfo val fileVersionUUID: String = "", /** * 每个文件的 UUID 和每个文件版本的 UUID 并不冲突 其中每个文件的初始版本的 UUID 和文件 UUID 相同 * * 以后如果想做版本分支的话 可以将文件版本的文件 UUID 指向另一个已存在的文件版本 这样形成一个树形结构 * * 可以再加一个字段 用于表示树形结构中的某一分支是否已经结束 或者重新设计也行 */ @ColumnInfo val explanation: String = "", ) /** * FileWithTagWithFileVersion 以 fileUUID 为主进行的查询结果 即包括了一个文件 文件所对应的所有 tag 和该文件的所有历史版本信息 */ data class FileWithTagWithFileVersion ( @Embedded val file: File, @Relation( parentColumn = "fileUUID", entityColumn = "tagId", associateBy = Junction(FileTagRef::class) ) val tags: List<Tag>, @Relation( parentColumn = "fileUUID", entityColumn = "fileUUID", ) val versions: List<FileVersion>, )

提示

Room 在一对多,多对多关系上确实是,没那么好用,必须声明一个关系实体,并且要联合查的话也得声明一个新类来查。关于为什么不能直接嵌套实体(注意这个语境,这里指的是嵌套实体而不是随便嵌套数据类),官网是这么说的:

image.png

在我的理解中,这段话指出的“不支持嵌套实体”的根本原因,在于不好处理被嵌套实体的查询时机。这毕竟不是服务端,想怎么查就怎么查。

  • 如果是懒加载模式,等到需要嵌套实体的时候再去数据库里查询,那么就极其容易出现数据库查询发生在主线程上的情况。那如果我要现启动一个协程去查,那还不如再写一个 dao 方法上来来的还好点。
  • 如果是一开始就全查,数据量小还好说,数据量大的情况下,全查太容易影响性能表现了。

在可选的两种解决方法都不是最佳选择的情况下,不支持嵌套实体就成了最好的选择了。

详见使用 Room 引用复杂数据 | Android Developers

数据库完成之后,其他的工作就和写 Note 是差不多的情况了,就写几个之前没用过的事吧。

  1. Room 启动事务
kotlin
/** * searchFileByKeyWord 按给定的 KeyWord 搜索文件 并且返回被搜索到的文件的 UUID */ @Transaction fun searchFileByKeyWord(keyWord: String): List<String> { val resultFromFile = searchFileByKeyWordFile(keyWord) val resultFromTag = searchFileByKeywordTag(keyWord) return (resultFromFile + resultFromTag).toSet().toList() }
  1. 应用 Material 主题 + 自动应用夜间模式

主要也是我自己写的组件全都一点自己的设计都没有,全都是默认配色,默认字体,不然没这么容易的。

kotlin
MaterialTheme ( colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() ) { FileView() }
  1. 文件处理

该说不说确实是方便啊(指语法糖)。

kotlin
/** * copyFileToTargetPath 该函数会将 fileUri 所指定的文件复制到 targetPath 所指定的位置下 */ fun copyFileToTargetPath(fileUri: Uri, targetPath: String, context: Context) { val inputStream = context.contentResolver.openInputStream(fileUri) val newFile = File(targetPath) val outputStream = FileOutputStream(newFile) try { inputStream?.use { outputStream.use { inputStream.copyTo(outputStream) } } } catch (e: Exception) { throw e } finally { inputStream?.close() outputStream.close() } } /** * deleteFile 该函数会尝试删除文件和该文件对应的父文件夹 */ fun deleteFile(filePath: String) { val file = File(filePath) val folderPath = file.parent file.delete() folderPath?.let { File(folderPath).delete() } }
  1. 启动 intent进行打开/分享文件

注意,应用间分享文件都用的是FileProvider

kotlin
/** * startIntentWithFile 按类型启动一个 intent 并且生成文件的临时 uri 将其放在 uri 中一起发送出去 * * 如果启动失败 会在 Toast 中显示异常 * * 默认情况下 intent 类型是 ACTION_SEND 对应文件分享 * * 打开文件是 ACTION_VIEW */ fun startIntentWithFile(context: Context, filePath: String, intentType: String = Intent.ACTION_SEND) { try { val intent = Intent(intentType) val file = File(filePath) val uri = FileProvider.getUriForFile(context, "com.misaka.workflow.fileprovider", file) intent.apply { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) setDataAndType(uri, MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)) } context.startActivity(intent) } catch (e: Exception) { Toast.makeText(context, "错误:${e.message}", Toast.LENGTH_LONG).show() } }

剩下的东西就写在问题里吧。

2 遇到的一些问题

2.1 导入不正常

如果在 ViewModel 里有这样的语句:

kotlin
var searchResultState by mutableStateOf<List<Int>>(listOf())

会报错:Type 'androidx. compose. runtime. MutableState<kotlin. collections. List<kotlin. Int>>' has no method 'getValue(FileViewModel, KMutableProperty1<*, *>)', so it cannot serve as a delegate. Type 'androidx. compose. runtime. MutableState<kotlin. collections. List<kotlin. Int>>' has no method 'setValue(FileViewModel, KMutableProperty1<*, *>, ERROR CLASS: Unresolved name: getValue)', so it cannot serve as a delegate for var (read-write property).

需要手动 import 进行修正:

kotlin
import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue

其他的导入不正常,一个是查实导入的类名,另一个是重启试试,尤其重启,百试不爽。

2.2 SnackBar 通知

SnackBar 很好看,相关 API 也很强大,总之设计的很好,只有一点:我没法在 ViewModel 层启动。如果必须在 View 层启动的话,我还得回调回到 View 层,说实话这个逻辑我不想拆到 View 层上,毕竟 View 层应该保证业务逻辑越少越好,有逻辑也是视图逻辑才对。而且这样还有一个问题,我没法处理这种场景:事件开始时在一个页面,该页面即将结束,返回到上一个页面时,也要显示通知。

所以为了应对这些问题,我按照这个视频 How to Show Snackbars From ANY Place In Your Compose App - Android Studio Tutorial 写了一个全局 SnackBar,并且封装了一下 SnackBar。

先是 SanckBar 通知要怎么管理,并且到达 View 上。为了全局收集 SnackBar 通知,用 Channel 做一个队列缓冲区,并且 collect 成流。之后在视图层上获取其生命周期并进行监听,在页面加载完成时,把这个流发射到 onEvent 函数中。这样能够保证,调用了这个函数的视图层,即使是重新加载过,也能够及时获取到 SnackBar 通知。

注:那个 ObserveAsEvents 函数里的俩 key,某种程度上算是为了手动触发再绑定的,并没有其他的作用。

kotlin
/** * SnackBarEvent 用于显示一个 SnackBar 通知 * * - message: 通知内容 * - action: 点击事件 * - duration: 通知显示保持时间 * - withDismissAction: 是否显示关闭通知按钮 */ data class SnackBarEvent ( val message: String, val action: SnackBarAction? = null, val duration: SnackbarDuration = SnackbarDuration.Long, val withDismissAction: Boolean = true, ) /** * SnackBarAction 用于定义 SnackBar 通知的点击事件 * * - labelName: 点击按钮文本 * - action: 点击后的回调 */ data class SnackBarAction ( val labelName: String, val action: () -> Unit, ) /** * SnackBarController 发送 SnackBar 通知的入口 */ object SnackBarController { private val _events = Channel<SnackBarEvent>() val event = _events.receiveAsFlow() /** * sendSnackBarEvent 发送 SnackBar 通知的入口函数 */ suspend fun sendSnackBarEvent(event: SnackBarEvent) { _events.send(event) } } /** * 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) } } } }

之后就是对 Scaffold 进行重新封装,把 SnackBar 一并封进去。在 View 层上用 ObserveAsEvents 函数获取最新的 SnackBar 通知,并且显示出来。

kotlin
/** * ScaffoldWithGlobalSnackBarNotification 启用了全局 SnackBar 通知的 Scaffold * * 本质是对原有的 Scaffold 进行封装 * * 如果想要显示 SnackBar 通知 示例如下: * * ``` kotlin * viewModelScope.launch(Dispatchers.IO) { * ... * withContext(Dispatchers.Main) { * SnackBarController.sendSnackBarEvent( * event = SnackBarEvent( * message = "添加文件 $fileName 成功!", * action = null, * duration = SnackbarDuration.Long, * withDismissAction = true, * ) * ) * } * } * ``` */ @Composable fun ScaffoldWithGlobalSnackBarNotification ( modifier: Modifier = Modifier, topBar: @Composable (() -> Unit) = {}, bottomBar: @Composable (() -> Unit) = {}, floatingActionButton: @Composable (() -> Unit) = {}, floatingActionButtonPosition: FabPosition = FabPosition.End, containerColor: Color = MaterialTheme.colorScheme.background, contentColor: Color = contentColorFor(containerColor), contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, content: @Composable ((PaddingValues) -> Unit) ): Unit { val snackBarState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() ObserveAsEvents(SnackBarController.event, snackBarState) { snackBarEvent -> scope.launch { snackBarState.currentSnackbarData?.dismiss() // 如果之前有 先给取消掉 val result = snackBarState.showSnackbar( message = snackBarEvent.message, actionLabel = snackBarEvent.action?.labelName, duration = snackBarEvent.duration, withDismissAction = snackBarEvent.withDismissAction ) if (result == SnackbarResult.ActionPerformed) { snackBarEvent.action?.action?.invoke() // 执行回调 } } } Scaffold ( modifier = modifier, topBar = topBar, bottomBar = bottomBar, floatingActionButton = floatingActionButton, floatingActionButtonPosition = floatingActionButtonPosition, containerColor = containerColor, contentColor = contentColor, contentWindowInsets = contentWindowInsets, snackbarHost = { SnackbarHost(hostState = snackBarState) { Snackbar (modifier = Modifier.zIndex(1f), snackbarData = it) } } ) { innerPadding -> content.invoke(innerPadding) } }

2.3 Outlined 风格边框

直接上代码吧,说实话这个结构真有点没看懂,摸索着写的,还得加上在 StackOverFlow 里面问的结果。

kotlin
/** * OutlinedStyleBorderWithLabel OutlinedTextField 风格的边框样式 * * 并且 label leadingIcon trailingIcon 这几个属性都可用 */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun OutlinedStyleBorderWithLabel( modifier: Modifier = Modifier, paddingValue: PaddingValues, label: @Composable (() -> Unit)? = null, leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, innerComponent: @Composable () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } Column ( modifier = modifier.padding(paddingValue).fillMaxWidth() ) { Spacer(modifier = Modifier.height(3.dp)) Box ( modifier = Modifier .fillMaxWidth() ) { OutlinedTextFieldDefaults.DecorationBox( value = "value", innerTextField = innerComponent, singleLine = true, enabled = true, label = label, placeholder = {}, visualTransformation = VisualTransformation.None, interactionSource = interactionSource, colors = OutlinedTextFieldDefaults.colors(), container = { OutlinedTextFieldDefaults.Container( enabled = true, isError = false, interactionSource = interactionSource, modifier = Modifier.fillMaxWidth() ) }, leadingIcon = leadingIcon, trailingIcon = trailingIcon ) } } }

StackOverFlow 问题链接:The label position of OutlinedTextFieldDefaults.DecorationBox is wrong

2.4 动态获取组件大小和位置

示例里已经有了大小(size)和位置(positionInParent)了。

kotlin
Text( modifier = Modifier .onGloballyPositioned { coordinate -> canvasDrawLineInfo.add(coordinate.positionInParent().y + (coordinate.size.height / 2)) }, text = "1" )

2.5 任意组件的点击效果

kotlin
Canvas( modifier = Modifier .weight(1f) .height(Util.pxToDp(canvasHeightInPx.intValue, context)) .clickable( onClick = { isDESC.value = !isDESC.value }, // 下面两行全都是点击效果 indication = ripple(), interactionSource = remember { MutableInteractionSource() } ) .animateContentSize( animationSpec = tween(durationMillis = 300) ) ) {}

2.6 动画效果

AnimatedContent,该组件是一个动画组件,它会在传入的targetState发生改变时启用动画效果。

kotlin
AnimatedContent ( modifier = Modifier.onGloballyPositioned { coordinate -> // 监听组件绘制位置 canvasDrawLineInfo.add(coordinate.positionInParent().y + (coordinate.size.height / 2)) }, targetState = value, transitionSpec = { // 指定动画效果 fadeIn(animationSpec = spring()) togetherWith fadeOut(animationSpec = spring()) }, label = "AnimatedContent" ) { it -> }

当然 modifier 也能指定动画效果,只是没有上面那个组件那么通用。

kotlin
Row( modifier = Modifier .animateContentSize( // 指定动画效果 animationSpec = tween(durationMillis = 300) ) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) {}

具体的动画效果详见:Jetpack Compose(6)——动画

3 什么是异步?

异步具体是什么?这个问题倒也不是一开始就有的,毕竟在最一开始的时候异步的概念确实很清晰:

异步,和同步相对的,对于某些耗时任务,可以先启动这个任务,然后在后续等待的过程中,先运行别的任务,等到任务结束时再回来继续运行。

但是随着对编程的了解越来越深,接触的代码越来越多,我对于异步所谓“单线程达到多线程同步相近的速度”的说法越来越怀疑。毕竟一方面来说,任务就在那里,即使现在不运行,迟早还是要运行的,而单线程所对应的 CPU 核心一定是只有一个,从并发角度来讲能同时处理的请求一定只有一个,那么它怎么和多核心的多线程同步处理多个请求相比?另一方面来说,多线程同步用了锁,用了共享内存,用了条件变量等等等等来实现线程之间的通信,从而保证运行的正确,如果异步真的就是这么简单,那岂不是显得多线程的优化很奇怪?

所以我实地看了看 js 的代码,想通了某些事情。比如说这段代码:

javascript
let result = await fetch('/test_api') let data = await result.json()

我没用那种链式调用的写法,因为看着真的很像同步,而且这种写法确实把请求给“异步”了,没阻塞。上面那种写法是阻塞了的:

javascript
let data = fetch('/test_api') .then( response => response.json() )

首先,我觉得得先弄明白,上面两行代码的具体含义中,哪些工作真的是 js 完成的?

  1. 构建一个 http 客户端?这个是浏览器或者 node.js 的活,至少不是这两行代码运行的那个时刻 js 要做的。
  2. 构建一个 http 报文?这个确实。
  3. 发送出去?这个半对。但是注意了,这个任务半道上就不是 js 要做的了。按五层结构来说,从应用层往下,js 就没活了,之后到物理层全都是操作系统要干的活,也就是说,这个工作在半道上就不是 js 这个进程的任务了。
  4. 等待?确实但没必要,这个就是异步定义里的“耗时”。
  5. 拿到结果之后对其进行解析,确实是 js 要完成的。注意这个解析只是从 http 报文中的 body 反序列化一个 json 出来,http 报文之前的形式跟 js 依然无关。

从上面的整体步骤中可以发现一点,所谓的“耗时任务”,其准确的代指的应该是目前进程需要等待的任务,某些计算密集型任务是不包括在内的。想一下常见后端业务中的这些“耗时任务”具体能指什么?数据库查询,网络请求,文件 io,这些任务都不是计算密集型任务,就是纯粹的需要等待而已。所以基于 js 的异步可以达到多线程同步的相近的速度这一点就不是不能理解。那么,异步的概念就可以被补充为这样:

异步,和同步相对的,对于某些耗时任务,可以先启动这个任务,然后在后续等待的过程中,先运行别的任务,等到任务结束时再回来继续运行。

耗时任务,具体指当前线程出于某些原因,只能等待这个任务完成才能获取任务结果的任务,而不是类似计算密集型任务这样,需要自己执行的任务。比如网络请求,文件 io 等等。

异步的定义到了这一步,是否还有问题呢?确实是还有。等到我开始使用 Go 和 Kotlin 的协程时,我发现异步的概念仍然不准确。

先来 Go 和 Kotlin 的协程示例代码:

go
// go 的代码仅作为示例 实际得看情况用 后续会说明 func GetCategoryList(c *gin.Context) { page, e := strconv.Atoi(c.DefaultQuery("page", strconv.Itoa(config.GlobalConfig.DefaultPage))) if e != nil { ErrorHandler(c, "Get Param page Error: "+e.Error()) return } size, e := strconv.Atoi(c.DefaultQuery("size", strconv.Itoa(config.GlobalConfig.DefaultSize))) if e != nil { ErrorHandler(c, "Get Param size Error: "+e.Error()) return } page = (page - 1) * size // page到offset的转换 // 之前可能都算某种业务逻辑 总之是拿到参数了 // 通过协程发起数据查询 go models.GetCategoryByColumn("") // 之后可能还有某些业务代码 ...... // 最后从 channel 中或者从 context 中拿结果 select { case result <- sqlExecResultChan: // 拿数据之类的 data = result.data case e <- sqlExecResult: if e != nil { ErrorHandler(c, "Get Result Error: "+e.Error()) return } } c.JSON(http.StatusOK, gin.H{ "code": 200, "data": map[string]any{ "count": count, "list": data, }, }) }
kotlin
// viewModel 层 var searchFileResult by mutableStateOf<List<FileWithTagWithFileVersion>>(listOf()) fun searchFileByKeyword(keyword: String) { viewModelScope.launch { isLoadingState = true withContext(Dispatchers.IO) { var searchFileUUIDResult = listOf<String>() val searchFileResultTemp = mutableListOf<FileWithTagWithFileVersion>() searchFileUUIDResult = repository.searchFileByKeyword(keyword) searchFileUUIDResult.forEach { uuid -> filesFlow.value.find { file -> file.file.fileUUID == uuid }?.let { it -> searchFileResultTemp.add(it) } } searchFileResult = searchFileResultTemp } }.invokeOnCompletion { viewModelScope.launch { isShowSearchResult = true isLoadingState = false } } } // view 层显示逻辑 fun ShowFileView( viewModel: FileViewModel = hiltViewModel(), navController: NavController ) { ... var displayFilesList = if (viewModel.isShowSearchResult) viewModel.searchFileResult else viewModel.filesFlow.collectAsStateWithLifecycle().value ... }

对于 Go 这段来说,在一个 Handler 中,我先启动一个协程来执行数据库查询,之后我先执行某些业务代码(反正只是示例,也别管没有数据库数据的情况下能有什么业务代码,看个意思就行),等到最后通过 Channel 拿到执行结果。这是不是异步?如果是,为什么之前从来没有在 Go 这边听过异步这回事?

Go 这边先按下不表,先说 Kotlin 的这段,它其实就有比较明显的异步特征:view 层的某些点击事件来调用 viewModel 层的函数,该函数直接在指定线程上启动一个协程来后台查数据,并且写到状态里。view 层通过状态变化来进行页面重组。这个过程中,主线程,也就是直接和用户交互的页面线程是没有阻塞的,查询数据库这种“耗时任务”被放在 IO 线程上。对于主线程来说,这就是标准的异步。也无怪乎官方文档这样写:

image.png

注意到了吗,这次在说异步之前有一个定语,"对于主线程来说,这就是标准的异步"。这次在说异步之前,我先声明了,这只是对于主线程而言的。实际的耗时任务中的耗时,只是被我用调度器(Dispatcher.IO)转移到 IO 线程上了。这一点对于 Go 来说,也是一样的,我通过启动一个协程的方式,将耗时任务转移到这个新的协程上了,对于原本的协程来说,这就是异步。

理解到了这一步之后,这个耗时任务具体是什么就已经不重要了,需要明确的就是,只要当前线程不能硬等着,那么所有其他的任务都可以是耗时任务

所以异步的概念仍然可以继续被更新:

异步,是一种编程理念,在某一个执行单元上,通过某种方式,将不是该执行单元所需要执行的任务转移到别的执行单元上来执行,并且能够通过某种方式在未来的某一时间点获得执行结果(不论这个任务是成功还是失败)的编程方式,就是异步。

这个概念就比较通用了,可以套在很多地方。对于 js 来说,它就把耗时任务转移到了别的进程上,自己没有等着执行完成(其实 js 这块的说法是有点模糊的,具体请配合JavaScript异步的底层原理一起理解,这个事件循环模型确实只能处理那些纯粹的需要等待的耗时任务),对于 Kotlin 来说,执行单元就是不同的线程,它把耗时任务通过协程的方式转移到了别的线程上继续执行,原本的线程就不阻塞了。

要特意提一嘴的是 Go 的,对于它,是要配合 GMP 模型来说的。它的执行单元可以认为是 Machine 和 Processor 的集合体,每当当前协程启动一个新的协程,新启动的协程就会进入队列进行排队,和原来的协程分别在可能是不同也可能是相同的 Machine 上执行完成。对于它来说,“转移”这个动作是由 Go 自己的调度器完成的。这部分请参见 GMP 模型会有更好的理解。

至于前面说,为什么那些代码仅作为示例,实际要看情况。因为一来是实际的 web 框架中,每次请求就已经在不同的协程上运行了,二来也是因为就目前来说,业务代码基本都没法脱离数据库查询的,要是有好几个数据库查询还好说,就一个查询的话确实也没什么必要另起一个协程去执行。

本文作者:御坂19327号

本文链接:

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