用Compose做一个视频下载软件—保存历史记录

145 阅读5分钟

1.jpg

距离上一次开篇10多天时间,到目前为止,我只加了一个特性——保存历史记录。

考虑到下载链接的历史记录没有保存的必要性,所以只做了一个保存路径的历史记录。

同时对界面做了更改,让其看起来更像一个可用软件了。

使用资源

正式开始之前先讲一下如何在 CMP 中获取资源,例如图片、文字或者尺寸。

4.png

图片资源和文字资源的管理方式在 Android 中是差不多的,使用 drawable 文件夹和 strings.xml 进行管理。

但是颜色和尺寸资源,不能使用这样的方式,需要写 Kotlin 代码。

我单独建立了一个 Dimens.kt 文件,内容如下:

import androidx.compose.ui.unit.dp

val IconButtonSize = 40.dp
val IconButtonPadding = 8.dp

val SmallIconButtonSize = 32.dp
val SmallIconButtonPadding = 8.dp
val PagePadding = 12.dp
val PaddingMedium = 8.dp

val SingleLineListItemHeight = 56.dp
val SingleLineListItemPaddingHorizontal = 16.dp

颜色和尺寸的管理方式一致,也需要代码实现。

在之前导入的主题文件中,Color.kt 就是了。

针对文字和图片资源,我写了一点扩展,这样可以在代码中使用类似 inPainter()inString() 方便获取资源。

@Composable
fun StringArrayResource.inStringArray():List<String> {
    return stringArrayResource(this)
}

@Composable
fun DrawableResource.inPainter(): Painter {
    return painterResource(this)
}

@Composable
fun StringResource.inString():String {
    return stringResource(this)
}

调整首页

2.png

首页添加了部分控件:

  • 下载链接的输入框和下载按钮。
  • 保存路径的输入框、历史记录的按钮和选择保存路径的按钮。

输入框使用 OutlinedTextField,该控件可以显示带有边线的输入框,同时设置一下在没有输入时的提示和标签。

OutlinedTextField(
    modifier = Modifier.weight(1f),
    value = input,
    singleLine = true,
    onValueChange = {
        input = it
    },
    label = {
        Text(
            text = Res.string.download_link_label.inString(),
        )
    },
    placeholder = {
        Text(
            text = Res.string.download_link_hint.inString(),
        )
    }
)

大概是这个效果:

3.gif

同理对保存路径的输入框做同样的处理。

路径选择

桌面软件的文件选择框是一个常用功能,通常用于选择文件进行上传或者选择文件夹用于下载。

5.png

上图是一个简单的 Windows 文件夹选择器。

那么在 CMP 中,应该如何实现呢?

当然没必要我们自己实现了(如果你自己可以的话,鼓励自己做),使用第三方的开源库filekit即可。

引入 filekit 之后,通过 val saveDir = FileKit.openDirectoryPicker() 就可以打开一个文件夹选择框了。当我们选择完成文件夹之后,openDirectoryPicker 会返回我们选择的路径。

好的,我们在本地路径的按钮上绑定一个点击事件,用于打开文件夹选择器。


// other code...

var dir by remember { mutableStateOf("") }

fun chooseFileSaveDir() {
    scope.launch(Dispatchers.IO) {
        val saveDir = FileKit.openDirectoryPicker()
        dir = saveDir?.file?.absolutePath.orEmpty()
    }
}

AppRoundFilledIconButton(icon = Res.drawable.save) {
    chooseFileSaveDir()
}

此时,我们就有文件夹选择功能了。

本地存储

我们依然使用第三方库KStore来实现本地存储,详细用法请参考该库的 Github 地址。

简单来讲,它使用 kotlinx.coroutineskotlinx.serializationkotlinx.io 协助实现对象的磁盘存储与恢复功能。

参照文档示例代码,我们引入 kstorekstore-file,然后定义对象:

@Serializable
data class DestinationHistory(val items: List<Destination>)

@Serializable
data class Destination(val path: String, val addTime: Long)

为了方便使用,我编写了一个 Store 类用于管理历史记录:

object Store {

    init {
        File("./cache/").mkdirs()
    }

    private val store: KStore<DestinationHistory> =
        storeOf(file = Path("./cache/destinations.json"), version = 0)

    // 历史记录的流
    val destinationHistory: Flow<DestinationHistory> = store.updates.filterNotNull()
    
    // 添加历史记录
    suspend fun addDestination(destination: String) {
        if (destination.isBlank()) {
            return
        }
        store.update { history ->
            val newItem = Destination(destination, System.currentTimeMillis())
            val olds = history?.items?.toMutableList() ?: mutableListOf()
            olds.removeIf { it.path == destination }
            olds.add(0, newItem)
            history?.copy(items = olds) ?: DestinationHistory(olds)
        }
    }
}

HomeViewModel

我们创建一个新的 HomeViewModel 用于处理首页 HomePage 的业务逻辑:

class HomeViewModel : ViewModel() {

    private val _destinationHistory = MutableStateFlow<ImmutableList<String>>(persistentListOf())
    val destinationHistory: StateFlow<ImmutableList<String>> = _destinationHistory.asStateFlow()

    init {
        viewModelScope.launch(Dispatchers.IO) {
            Store.destinationHistory.collectLatest { history ->
                _destinationHistory.update {
                    history.items.map { it.path }.toImmutableList()
                }
            }
        }
    }
}

HomePage 中监听 destinationHistory 来获取当前的本地保存路径的历史记录。

历史记录的显示,我使用了 ModalBottomSheet + LazyColumn 的组合,即在屏幕下方打开一个下拉框进行显示:


fun showDestinationHistory() {
    showDestinationHistory = true
}

fun hideDestinationHistory() {
    showDestinationHistory = false
}

if (showDestinationHistory) {
    ModalBottomSheet(
        onDismissRequest = {
            hideDestinationHistory()
        },
        sheetState = sheetState,
    ) {

        LazyColumn(modifier = Modifier.fillMaxSize()) {
            itemsIndexed(destinationHistory, key = { a, b -> b }) { index, value -> 
                // other code
            }
        }
    }
}

ModalBottomSheet 需要提供一个 SheetState,同时在响应 onDismissRequest 的时候,不显示 ModalBottomSheet

内部使用 LazyColumn 加载历史记录的每一项,因为每一项都是文字内容,所以简单的使用 Text 即可。我们给 Text 添加一个点击事件——当触发点击的时候,将点击的内容输入到保存地址框中,同时隐藏该 ModalBottomSheet


fun chooseHistory(history: String) {
    dir = history
    scope.launch {
        sheetState.hide()
    }.invokeOnCompletion {
        if (!sheetState.isVisible) {
            hideDestinationHistory()
        }
    }
}


// other code...

Box(
    modifier = Modifier.height(SingleLineListItemHeight).fillMaxWidth()
        .clickable {
            chooseHistory(value)
        }
) {
    Text(
        value,
        modifier = Modifier.align(Alignment.CenterStart)
            .padding(horizontal = SingleLineListItemPaddingHorizontal)
    )
}

注意这里隐藏的用法,在 sheetState.hide() 完成之后,需要让 showDestinationHistoryfalse 才能完全隐藏 ModalBottomSheet

读者可以自己试试这里不隐藏 ModalBottomSheet 会发生什么结果。(提示:千万不要以为App卡死了)

最后一步逻辑,在我们点击下载的时候,保存历史记录即可。

此时点击历史记录输入框右边的按钮的时候,会显示我们的历史记录。

我们来看下效果:

6.gif

Snackbar

在开始下载之前,我们还需要做一点额外工作,就是显示提示信息。

目前有三种情况需要处理:

  • 没有输入下载链接;
  • 没有输入保存路径;
  • 下载链接不是 http(s) 格式的。

Android 中,一般使用 Toast 进行提示的显示,但是 ToastAndroid 平台的系统支持。在桌面端是没有这个的。所以我选用了 Snackbar 作为提示信息显示的控件。


val snackHostState = remember {
    SnackbarHostState()
}

SnackbarHost(
    hostState = snackHostState,
    modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = PagePadding),
) {
    Snackbar(snackbarData = it)
}

使用 Snackbar 需要一个 SnackbarHost,而 SnackbarHost 需要一个 SnackbarHostState 用来控制显示。

现在,我们针对上面的三种情况,编写相关的逻辑:

fun startDownload() {
    scope.launch {
        if (input.isEmpty()) {
            snackHostState.showSnackbar(Res.string.download_link_empty.getString())
        } else if (dir.isEmpty()) {
            snackHostState.showSnackbar(Res.string.download_destination_empty.getString())
        } else if(!input.isValidHttpUrl()) {
            snackHostState.showSnackbar(Res.string.download_link_not_url.getString())
        } else {
            // 下载
        }
    }
}

使用 snackHostState.showSnackbar 显示 Snackbar,参数填入想要显示的文字即可。

我们看下效果:

7.gif

总结

  • 使用 CMP 的资源
  • filekit 选择保存路径
  • KStore 保存历史记录
  • ModalBottomSheet 显示历史记录
  • Snackbar 显示提示信息

源码地址:
github.com/kapaseker/Y…