这是一个几乎全部使用Compose实现UI各组成部分的纯Kotlin Android App,应用取名Compose Many是因为最初想实现集各种小功能的工具软件,当然主要还是想也借此来学习Compose的整个开发流程。不过目前只做好了音乐部分。
Compose目前正式版已经发布到了1.0.2(Alpha版本是1.1.0),目前来看官方更新速度不算太快,第一个正式版应该还是以稳定性为主。希望后续大版本更新时,能像Flutter2.0一样,功能更全面的同时也带来更丰富的生态。
已经完成的功能主要就是歌单列表的播放和评论查看,由于接口众多,而APP主要利用空闲时间开发的,时间有限只做了推荐歌单和个人歌单的获取,常听的歌曲可以先听听了~ 然后评论功能暂时只能查看,点赞、回复这些后面有时间再做。
服务器端
使用Binaryify大佬整理的网易云API NeteaseCloudMusicApi,可以非常方便地通过RESTful API访问各个数据接口,仓库里也提供了开箱即用的部署方案,这里就选用其中的Vercal方案:
于是借助宝藏网站 Vercel,就免费拥有自己的域名,并且可以在上面部署自己的代码。当然,Vercel也不是完全免费的,它对于一段时间内的访问量有所限制,达到比较高的访问量时会认为超出了个人使用用途,域名入口可能会被关停。因此作为学习目的,最好就是自己注册一个Vercel账号,然后App调用自己专属的API地址
客户端架构
应用的界面不多,界面表现层使用MVVM,音乐功能为单Activity
+多Fragment
,Fragment
内容使用Compose构建。
其中PlaySongsViewModel
生命周期跟随Activity
:
private val playSongsViewModel by activityViewModels()
复制代码
这样就能实现无论是首页、歌单页底部的播放器小控件(PlayWidget
),还是歌曲播放界面,他们的音乐播放状态都一致来源于PlaySongsViewModel
,任何一处的播放操作都能在其他页面得到正确的展示。依靠ViewModel作用域合理划分,自然地实现了状态单一来源,而不必使用类似EventBus这样容易造成状态混乱的通知。
项目中使用Compose构建的界面,尽量遵循了官网提出的
状态提升
达成“单向数据流”,具体参考官网:developer.android.google.cn/jetpack/com…
项目使用了Jetpack Hilt
管理所有依赖,它与其他大部分Jetpack组件都能提供很好支持,无论View、ViewModel还是Repository层都能很轻松地获取到需要的依赖项。
网络数据源使用Retrofit
、数据库ORM使用Jetpack ROOM
、应用持久化数据使用Jetpack DataStore
(ProtoBuf实现)
界面导航
界面跳转使用Jetpack Navigation
,方案选择经过几次迭代:
最开始打算单纯使用navigation的compose集成,可以像以下代码这样非常方便地实现两个composable界面的跳转:
val navController = rememberNavController()
NavHost(navController = navController, startDestination = ScreenRoutes.AboutUs.path) {
composable(ScreenRoutes.AboutUs.path) {
AboutUsPage(onBackClick = { finish() }) {
navController.navigate(ScreenRoutes.Privacy.path)
}
}
composable(ScreenRoutes.Privacy.path) {
HtmlDocumentViewer(title = "隐私政策")
}
... ...
}
复制代码
完全使用ComposeNavigator
的好处是可以做到Compose界面跳转的过渡,并且后续版本还能实现界面间共享元素。
但实际使用中发现上述方式目的地之间无法传递自定义类型的参数,于是想把Fragment/Activity的Navigator和ComposeNavigator
混用,但发现跳转Fragment返回时(Navigation对于Fragment导航跳转默认使用replace,因此返回时重新调用onCreateView)重新创建的ComposeView中的总是空白。通过查看源码和调试,发现NavHost
中:
// NavHost.kt
//
// lifecycle#currentState状态大于STARTED时才做渲染
val backStackEntry = transitionsInProgress.lastOrNull { entry ->
entry.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
} ?: backStack.lastOrNull { entry ->
entry.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}
... ...
if (backStackEntry != null) {
Crossfade(backStackEntry, modifier) { currentEntry ->
...
}
}
复制代码
而从回退栈返回是重组NavHost时状态是CREATED
,无法获得backStackEntry
,因此也无法显示。解决的方法想到的是在lifecycle进入到STARTED
时改变NavHost
的状态主动触发重组,比如透明度从0f -> 1f:
var navAlpha by remember { mutableStateOf(0f) }
LaunchedEffect(key1 = true, block = {
lifecycle.whenStarted { navAlpha = 1.0f }
})
复制代码
这样一来返回时界面就能正常显示了,不过为了统一导航最终还是选择了全部使用单纯的FragmentNavigator
做界面导航,暂时放弃ComposeNavigator
,当前的navigation版本上(2.4.0-alpha06)对于compose的支持似乎还没有完全稳定。
可折叠标题栏
Compose暂时没有类似View系统中的CollapsingToolbarLayout
和CoordinatorLayout
,或者Flutter中CustomScrollView
+SliverAppBar
那样方便实现定制滑动可折叠标题的控件,因此最后找到一些其他的实现方式:
Modifier
的nestedScroll
有一个实现折叠标题栏的例子 androidx.compose.ui.input.nestedscroll | Android Developers (google.cn)val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = toolbarOffsetHeightPx.value + delta
toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
return Offset.Zero
}
}
}
... ...
TopAppBar(
modifier = Modifier
.height(toolbarHeight)
.offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) },
title = { Text("toolbar offset is ${toolbarOffsetHeightPx.value}") }
)
复制代码
主要就是嵌套滑动算出的偏移关联到TopAppBar中,问题主要是上拉时只要开始上拉就把折叠展开,而不是上拉到列表顶部后才展开。也许可以通过其他的计算方式达到效果,但整体比较复杂。
LazyColumn
可以得到滑动过程的状态,然后将标题栏作为单独的item{}
放在第一项,可以比较灵活地实现自己的可折叠状态栏@Composable
fun CollapsingEffectScreen() {
val items = (1..100).map { "Item $it" }
val lazyListState = rememberLazyListState()
var scrolledY = 0f
var previousOffset = 0
LazyColumn(
Modifier.fillMaxSize(),
lazyListState,
) {
item {
Image(
modifier = Modifier
.graphicsLayer {
scrolledY += lazyListState.firstVisibleItemScrollOffset - previousOffset
translationY = scrolledY * 0.5f
previousOffset = lazyListState.firstVisibleItemScrollOffset
}
)
}
items(items) {
... ...
}
}
}
复制代码
使用graphicsLayer
实现关联偏移、折叠、透明度等等可以避免频繁重组。
onebone/compose-collapsing-toolbar: A simple implementation of collapsing toolbar for Jetpack Compose (github.com)
CollapsingToolbarScaffold(
state = rememberCollapsingToolbarScaffoldState(), // provide the state of the scaffold
toolbar = {
// contents of toolbar go here...
}
) {
// main contents go here...
}
复制代码
唱片动画
Compose中实现控件过渡动画会发现比View系统的简单很多,并且表现力也更强。比如图片的无限旋转动画使用下面的代码就可以很容易实现:
val infiniteTransition = rememberInfiniteTransition()
val rotation by infiniteTransition.animateFloat(initialValue = 0f, targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(15 * 1000, easing = LinearEasing)
)
)
Image(
modifier = Modifier
.graphicsLayer {
rotationZ = rotation
}
)
复制代码
但是唱片动画有个特点,就是歌曲可以暂停,动画也需要可暂停并且原地续播。以Flutter为例,它可以通过AnimateController
的stop、forward方法暂停、继续动画,但Compose的动画系统用起来更方便了却也缺少了这种可以直接控制动画流程的API,为了实现这样的需求,用了更底层的Animatable。因为无限动画的起点和终点必须相差360度才能有无限循环效果,并且起始角度是当前角度值,所以通过角度的取余让它在0~720度范围内,达到视觉上无缝的无限旋转动画:
/**
* 无限循环的旋转动画
*/
@Composable
private fun infiniteRotation(
startRotate: Boolean,
duration: Int = 15 * 1000
): Animatable {
var rotation by remember { mutableStateOf(Animatable(0f)) }
LaunchedEffect(key1 = startRotate, block = {
if (startRotate) {
//从上次的暂停角度 -> 执行动画 -> 到目标角度(+360°)
rotation.animateTo(
(rotation.value % 360f) + 360f, animationSpec = infiniteRepeatable(
animation = tween(duration, easing = LinearEasing)
)
)
} else {
rotation.stop()
//初始角度取余是为了防止每次暂停后目标角度无限增大
rotation = Animatable(rotation.value % 360f)
}
})
return rotation
}
复制代码
图片圆角、模糊
图片的圆角、圆形裁切和模糊都能通过Coil很容易地实现:
Image(
painter = rememberImagePainter(song?.picUrl?.limitSize(200), builder = {
transformations(
CircleCropTransformation(),
BlurTransformation(LocalContext.current, 16f),
)
})
)
复制代码
这里之前在实现模糊背景时存在一个缺陷,就是白色的图标(按钮)在浅色背景下会与背景融在一起而无法看清。观察了网易云音乐的原版App,发现即使白色背景它也会被调暗,以适应浅色的前景按钮和图标。因此顺着这个思路,我这儿采用的方法是在模糊背景上遮盖一层半透明的灰黑色蒙层:
//模糊虚化的封面作为背景
Image(
...
modifier = Modifier
.drawWithContent {
drawContent()
//背景遮上半透明颜色,改善明亮色调的背景下,白色操作按钮的显示效果
drawRect(Color.Gray, alpha = 0.7f)
},
...
)
复制代码
这样即使模糊背景整体偏亮色,上面的浅色按钮也能比较容易看清。
除此之外,应该也能通过Image的colorFilter混合减暗颜色来达到更好的效果(未测试过):colorFilter = ColorFilter.tint(Color.Gray, BlendMode.Darken)
底部弹窗
底部弹窗在Compose中实现起来也是非常简单
ModalBottomSheetLayout(
//弹窗内容
sheetContent = { ReplySheet(floorComment) },
sheetState = sheetState,
sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
) {
//主内容
CommentMain(song, commentCount, commentList, sortType) {
viewModel.loadFloorReply(it.commentId)
scope.launch { sheetState.show() }
}
}
//需要返回时收起弹窗,这里处理返回键行为
BackHandler(sheetState.currentValue != ModalBottomSheetValue.Hidden) {
scope.launch { sheetState.hide() }
}
复制代码
第一次掘金上发文,文章排版比较散乱。这个项目作为自己的第一个Compose应用,并且也是主要练习为目的,APP中很多功能都不完善,并且对于Compose的了解还不是非常深入,有些部分实现可能不是最佳实践。整体使用开发下来的直观感受还是与传统View很大不同,尤其通过各种修饰符就能将内置控件定制为自己想要的样式,然后进行组合排布,重复的样板代码少了很多。
Compose开发的界面在某些部分还是能看出不完善的地方,比如LayzColumn
列表滑动的流畅性上是和RecyclerView还是有差距,还有很多API都还有试验性注解(即API还不稳定,后续可能变动)。性能方面也是官方着重在后续版本优化的点。
可以预见的是,现代的声明式UI未来应该会成为一个高效的UI开发模式,但能不能在Android中成为主流就看官方的开发力度和开发者们的接受度了~
最后还有本APP的源码地址,有空还会补充更多功能:
Mr-lin930819/ComposeMany: 使用jetpack compose构建的app (github.com)