主要内容:
File 这个模块,先说数据库设计。一个文件,它所需要存储的有这么几项:
按照这个需求,就有了这个表结构:
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 在一对多,多对多关系上确实是,没那么好用,必须声明一个关系实体,并且要联合查的话也得声明一个新类来查。关于为什么不能直接嵌套实体(注意这个语境,这里指的是嵌套实体而不是随便嵌套数据类),官网是这么说的:
在我的理解中,这段话指出的“不支持嵌套实体”的根本原因,在于不好处理被嵌套实体的查询时机。这毕竟不是服务端,想怎么查就怎么查。
在可选的两种解决方法都不是最佳选择的情况下,不支持嵌套实体就成了最好的选择了。
数据库完成之后,其他的工作就和写 Note 是差不多的情况了,就写几个之前没用过的事吧。
kotlin/**
* searchFileByKeyWord 按给定的 KeyWord 搜索文件 并且返回被搜索到的文件的 UUID
*/
@Transaction
fun searchFileByKeyWord(keyWord: String): List<String> {
val resultFromFile = searchFileByKeyWordFile(keyWord)
val resultFromTag = searchFileByKeywordTag(keyWord)
return (resultFromFile + resultFromTag).toSet().toList()
}
主要也是我自己写的组件全都一点自己的设计都没有,全都是默认配色,默认字体,不然没这么容易的。
kotlinMaterialTheme (
colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
) {
FileView()
}
该说不说确实是方便啊(指语法糖)。
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()
}
}
注意,应用间分享文件都用的是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()
}
}
剩下的东西就写在问题里吧。
如果在 ViewModel 里有这样的语句:
kotlinvar 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 进行修正:
kotlinimport androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
其他的导入不正常,一个是查实导入的类名,另一个是重启试试,尤其重启,百试不爽。
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)
}
}
直接上代码吧,说实话这个结构真有点没看懂,摸索着写的,还得加上在 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
示例里已经有了大小(size)和位置(positionInParent)了。
kotlinText(
modifier = Modifier
.onGloballyPositioned { coordinate ->
canvasDrawLineInfo.add(coordinate.positionInParent().y + (coordinate.size.height / 2))
},
text = "1"
)
kotlinCanvas(
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)
)
) {}
AnimatedContent,该组件是一个动画组件,它会在传入的targetState
发生改变时启用动画效果。
kotlinAnimatedContent (
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 也能指定动画效果,只是没有上面那个组件那么通用。
kotlinRow(
modifier = Modifier
.animateContentSize( // 指定动画效果
animationSpec = tween(durationMillis = 300)
)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {}
具体的动画效果详见:Jetpack Compose(6)——动画
异步具体是什么?这个问题倒也不是一开始就有的,毕竟在最一开始的时候异步的概念确实很清晰:
异步,和同步相对的,对于某些耗时任务,可以先启动这个任务,然后在后续等待的过程中,先运行别的任务,等到任务结束时再回来继续运行。
但是随着对编程的了解越来越深,接触的代码越来越多,我对于异步所谓“单线程达到多线程同步相近的速度”的说法越来越怀疑。毕竟一方面来说,任务就在那里,即使现在不运行,迟早还是要运行的,而单线程所对应的 CPU 核心一定是只有一个,从并发角度来讲能同时处理的请求一定只有一个,那么它怎么和多核心的多线程同步处理多个请求相比?另一方面来说,多线程同步用了锁,用了共享内存,用了条件变量等等等等来实现线程之间的通信,从而保证运行的正确,如果异步真的就是这么简单,那岂不是显得多线程的优化很奇怪?
所以我实地看了看 js 的代码,想通了某些事情。比如说这段代码:
javascriptlet result = await fetch('/test_api')
let data = await result.json()
我没用那种链式调用的写法,因为看着真的很像同步,而且这种写法确实把请求给“异步”了,没阻塞。上面那种写法是阻塞了的:
javascriptlet data = fetch('/test_api')
.then( response =>
response.json()
)
首先,我觉得得先弄明白,上面两行代码的具体含义中,哪些工作真的是 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 线程上。对于主线程来说,这就是标准的异步。也无怪乎官方文档这样写:
注意到了吗,这次在说异步之前有一个定语,"对于主线程来说,这就是标准的异步"。这次在说异步之前,我先声明了,这只是对于主线程而言的。实际的耗时任务中的耗时,只是被我用调度器(Dispatcher.IO
)转移到 IO 线程上了。这一点对于 Go 来说,也是一样的,我通过启动一个协程的方式,将耗时任务转移到这个新的协程上了,对于原本的协程来说,这就是异步。
理解到了这一步之后,这个耗时任务具体是什么就已经不重要了,需要明确的就是,只要当前线程不能硬等着,那么所有其他的任务都可以是耗时任务。
所以异步的概念仍然可以继续被更新:
异步,是一种编程理念,在某一个执行单元上,通过某种方式,将不是该执行单元所需要执行的任务转移到别的执行单元上来执行,并且能够通过某种方式在未来的某一时间点获得执行结果(不论这个任务是成功还是失败)的编程方式,就是异步。
这个概念就比较通用了,可以套在很多地方。对于 js 来说,它就把耗时任务转移到了别的进程上,自己没有等着执行完成(其实 js 这块的说法是有点模糊的,具体请配合JavaScript异步的底层原理一起理解,这个事件循环模型确实只能处理那些纯粹的需要等待的耗时任务),对于 Kotlin 来说,执行单元就是不同的线程,它把耗时任务通过协程的方式转移到了别的线程上继续执行,原本的线程就不阻塞了。
要特意提一嘴的是 Go 的,对于它,是要配合 GMP 模型来说的。它的执行单元可以认为是 Machine 和 Processor 的集合体,每当当前协程启动一个新的协程,新启动的协程就会进入队列进行排队,和原来的协程分别在可能是不同也可能是相同的 Machine 上执行完成。对于它来说,“转移”这个动作是由 Go 自己的调度器完成的。这部分请参见 GMP 模型会有更好的理解。
至于前面说,为什么那些代码仅作为示例,实际要看情况。因为一来是实际的 web 框架中,每次请求就已经在不同的协程上运行了,二来也是因为就目前来说,业务代码基本都没法脱离数据库查询的,要是有好几个数据库查询还好说,就一个查询的话确实也没什么必要另起一个协程去执行。
本文作者:御坂19327号
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!