前言
"使用JetPack Compose 更快地构建更好的应用程序"
Jetpack Compose 是 Android 推荐的用于构建本机 UI 的现代工具包。它简化并加速了 Android 上的 UI 开发。使用更少的代码、强大的工具和直观的 Kotlin API 快速让您的应用程序栩栩如生。
作为Android开发者,xml布局和Compose布局大家应该很熟悉,而Compose作为Android平台上第二款支持声明式UI的框架,第一款是Flutter框架了。
声明式UI有哪些特点,作为开发者应该如何学习呢?
关于Compose UI
随着Compose UI的日渐成熟,作为Android开发者,很多UI方面的技术又得重新再来,即便是成熟的Android开发者,也得重新去理解一些设计思想,因此,某些方面可以说,在Compose UI这里大家的起跑线是一样的。
作为一款UI框架,无论是xml和compose ui,其实有特定的学习路线,我们要围绕下面几个点,就能快速入门Compose UI
- 主题风格
- 图文展示
- 资源加载
- 布局
- 绘制
- 动画
- 事件
- 状态
但是,如何与业务关联,作为声明式UI,天然的优势就是双向绑定了,主要从下面几个点去着手。
- 业务驱动状态
- 状态驱动UI
- UI驱动事件
- 事件驱动业务
声明式UI有哪些特点呢?
- 不用标记节点:不需要设置name或者id
- 天然双向驱动:不需要通过bridge方式建立映射,可有效简化代码复杂度
- 更快的开发效率:避免了很多机械式的操作
比较令人疑惑的是,迄今为止似乎没人知道为啥叫Jetpack Compose,特别是Jetpack该怎么理解呢?
实际上Google在文字创造上一直很处于前沿,比如"Google"本身就没有什么意义,也不是单词。Android的每个版本都会有名称,但即便这样你还得翻阅android.os.Build类去查阅这些代号,平时你也不会给别人说你用的是Hello Kitty版本。包括Android上的Material UI,依稀记得以前称之为Material Language,不知道后来为什么变成了Material UI了,显然,我觉得「Jetpack Compose」这个也有最终有可能完全变成「Compose UI」这种叫法。
那么,是不是声明式UI完美无缺呢?
也是不,在目前来说,Compose UI一些组件如Pager还是不成熟的,另外性能方面也有些不足,这也就呼应了本篇开头的那句话.
"使用JetPack Compose 更快地构建更好的应用程序"
其实,开发者显然期待的是
"使用JetPack Compose 更快地构建更好的「更快的」应用程序"
在软件开发中,【性能快】可以避免很多问题。
与Flutter对比
运行模式差异
相比Flutter,Compose在一些方面更加先进,得益于Kotlin编译器的作用,作为一门新式语言,Kotlin有大量的关键词、注解、语法糖来快速转换UI。Kotlin目标是为了加速开发,实现一套代码跨平台运行,因此通常你开不到源码的那些API实际上是通过编译器生成的,为什么这样做呢?主要还是Kotlin的理念,通过编译实现一套代码跨平台,这种编译产出是支持各平台可执行的代码,比如android上产出是JVM可以执行java bytecode,当然linux平台还可以编译出native code,那么显然理论上也可以产出kotlin->dart byetcode这种代码。
那么Flutter是怎么回事呢?
Flutter相比Compose ,其主要开发语言是Dart,其理念更加接近JVM,直接打包虚拟机的方式,其目的也是要实现一套代码跨平台运行,借助Dart VM在运行时生成更底层的汇编代码 (native code)。
综上,他们的目标是一致的。
目前,跨平台方面一致围绕两种路线发展,一种是通过更底层的方式,实现多种语言同时在一个虚拟机上运行,另一种则是将代码编译为运行平台的字节码。
比如graal vm,通过虚拟机手段避免差异,实现多种语言跨平台运行,这是一种"多语言对一VM,一VM对多平台"的手段,而kotlin是"一语言对多语言,多语言对多平台"的手段。
当然,目前还有WASM,这种语言旨在统一语法树来实现更底层的兼容。
目前来说各有优势,kotlin生成dart 字节码理论上也是可以的,反过来,如果使用graal vm,dart也可以直接在android上跑。
布局差异
布局方面,Flutter的Widget是显示的节点组装,而在Compose这里变成了隐式的节点组装,对于代码可读性而言,flutter相对友好一些,毕竟Dart更像Java的方式,而Kotlin由于语法糖较多,因此存在很多隐式调用的逻辑,不那么容易看到一些源码。使用方面,Compose更加简洁一些,不用类似Flutter的那种child,而且是纯函数实现。
状态管理差异
说到状态管理,其实这点要结合语言的特性,Compose推荐是各种类似闭包的remember,而Flutter比较关注的是集中式管理。
当然,这也和语言的特性有关,应该尽可能从语言方面去思考,比如redux在flutter上的使用就很失败,究其原因主要是没有利用好stream,同时让flutter的开发效率变得更低,索性后来有了GetX、Provider。
可扩展性
在灵活性方面,Kotlin其实要比Dart灵活很多,在UI层面,Compose做法非常新颖,比如有状态函数和无状态函数,另外还有各种remember以下,但这方面会不会成为kotlin的包袱呢。目前来说,官网推进的状态提升竟然是callback机制,这种无异于"地狱回调",而Flutter来说,状态管理本身比较集中。可扩展性方面,两者差距不大,但是在组件自身上,kotlin其实灵活度更高,主要体现在Modifier的各种draw函数上,如果Modifier不支持的属性,通过Modifer就能实现转换,甚至还能干预到最终样式。为什么能这样呢,因为任何组件都需要绘制的,Modifier提供的类似Hook的机制,更加强大。
性能
目前来说,相比Flutter而言,Compose的一些组件性能很不理想,这点在模拟器中表现更加明显,Compose显然还需要提升性能,不然低端机型甚至iOT设备上就会和Compose相见无缘。
事件
无论Flutter 还是Kotlin,他们的起点都是多点触控,这相当于比通常的android View处理层次更高一些,不过还是遵循dispatchInputEvent和finishInputEvent那套逻辑。两者的MotionEvent都是支持多点触控的,使用起来也都很简单,差异不大。不过,也有些差异的地方,比如flutter有让人比较难理解的InkWell,而Compose则有MotionEventSpy(事件间谍)。
自定义组件
Flutter和Compose 都能接入原生组件,同时都支持通过Canvas绘制,但前面说过,Compose UI的任何支持Modifier组件理论上都可以绘制。至于布局自定义方面,两者也支持自定义布局,但Compose的Modifier 更加灵活。
框架选择
在开发框架选择这方面,就目前而言,有一条不变的规律:
开发效率要先于运行效率
这个很容易理解,kotlin开发效率比C++高,但运行效率高的C++却无法打败kotlin。
开发中要使用flutter还是Compose,其实这个一定要看业务,作为开发者,要做到两件事。
- 好钢要用在刀刃上
- 杀鸡不要用宰牛刀
为了解决很小的问题,引入一个很大的框架是不是很合适呢?再比如说,纯业务交互app,完全不涉及底层,你用哪种不行呢 ?
实践
本篇说了很多总结性的内容,下面本篇会通过三个案例,来体验一下Compose的魅力。
手电筒效果
手电筒效果在之前文章中有过设计,不过,本篇的主要代码还是官网的demo基础上改造的,相比而言,使用Modifier的draw函数,灵活性非常高。
在这个案例中,我们利用MotionEventSpy修复了官网的按下时触点位置不准确或者偏倚太大的问题,另外,我们会看到remember托管的变量隐式转换。
源码如下
java
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val imageResource = ImageBitmap.imageResource(resources, R.mipmap.img_pic)
setContent {
MainComposeTheme(imageResource)
}
}
}
@Composable
fun MainComposeTheme(imageResource: ImageBitmap) {
ComposeTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
modifier = Modifier
.fillMaxSize()
.drawBehind {
drawImage(
image = imageResource,
dstSize = IntSize(size.width.toInt(), size.height.toInt())
)
}
) {
val greetingState = Greeting("Android")
Log.d("MainComposeTheme","greetingState $greetingState")
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier):Any {
var pointerOffset by remember { //闭包作用
mutableStateOf(Offset(0f, 0f))
}
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput("dragging") {
detectDragGestures(onDragStart = {
//pointerOffset和it类型不同,这里会隐式转换,实现拖转开始点赋值给pointerOffset
pointerOffset = it //拖转一定距离后才会触发此处的调用
}) { change, dragAmount ->
pointerOffset += dragAmount
}
}
.motionEventSpy {
if (it.actionMasked == MotionEvent.ACTION_DOWN) {
pointerOffset = Offset(it.x, it.y) //获取按下的位置
}
}
.onSizeChanged {
pointerOffset = Offset(it.width / 2f, it.height / 2f)
}
.drawWithContent {
// draws a fully black area with a small keyhole at pointerOffset that'll show part of the UI.
drawRect(
Brush.radialGradient(
listOf(Color.Transparent, Color.Black),
center = pointerOffset,
radius = 120.dp.toPx(),
)
)
}
) {
Text(
text = "Hello $name!,Welcome to use compose",
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(Alignment.CenterVertically)
.drawWithContent {
},
textAlign = TextAlign.Center,
onTextLayout = {
Log.d("A", "onTextLayout")
}
)
}
return pointerOffset
}
当然,代码中我们看不到看不见的还有状态订阅,这种理论上是赋值操作做了转换,这部分我没有细看,后续有时间分析。
第二点我们看到,Brush的作用,其类似android Paint的Shader,不过上面的代码使用Brush的会频繁创建对象,这点没有android View的Shader setLocalMatrix好。
动画偏倚效果
下面是一个简单的位置偏移动画,也是来自JetPack Compose官方教程中的
在这个动画中,还有一点需要注意的是,偏移方式是通过Offset方式,类似Android中的View修改Left、Top、Right、Bottom,在Android View中此类动画性能一般,在Compose中理论上也不会太理想,实现偏移动画这方面应该还有其他方式。
不过,这不是重点,重点是我们可以看到,在Modifier中直接修改Compose UI的相对位置。
我们知道,在Compose中是有padding的,但是没有margin,一些博客中建议用Border代替Margin,理论上也行,但是Border部分的点击事件如何屏蔽呢?其实使用layout方式可能更好。
java
class AnimationActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AnimationComposeTheme()
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun AnimationComposeTheme() {
ComposeTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
var toggled by remember {
mutableStateOf(false)
}
val interactionSource = remember {
MutableInteractionSource()
}
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxSize()
.clickable(indication = null, interactionSource = interactionSource) {
toggled = !toggled
}
) {
val offsetTarget = if (toggled) {
IntOffset(150, 150)
} else {
IntOffset.Zero
}
val offset = animateIntOffsetAsState(
targetValue = offsetTarget,
label = "offset"
)
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Yellow)
)
Box(
modifier = Modifier
.layout { measurable, constraints ->
val offsetValue = if (isLookingAhead) offsetTarget else offset.value
val placeable = measurable.measure(constraints)
layout(
placeable.width + offsetValue.x,
placeable.height + offsetValue.y
) {
placeable.placeRelative(offsetValue)
}
}
.size(100.dp)
.background(Color.Red)
)
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Cyan)
)
}
}
}
}
在这个Demo中,我们就会看到一些隐式的转换,对于开发者来说有些难以理解,不过,如果想看具体实现,最好从bytecode角度去审查,因为kotlin的很多代码都是从bytecode部分才能看出它实际上的调用。
实现Tab + Pager
Tab和Pager是非常经典且流行程度很高的布局,我本篇使用的是Foundation 1.5的版本,在滑动过程中PageState有很多不稳定的Bug。比如currentPage不稳定可以理解,从4->1 中间有4->2->3->1,毕竟要驱动indicator,但是targetPage也不稳定,这就有点说不过去了 。
另外,如果在无法滑动时继续滑动,还可能出现targetPage向相反方向,当然,我看一些博客使用的是snapshotFlow去防抖监听,但是这种也是有问题的,只不过是减少了概率,如果线程性能慢一些的话,出现频率就会很明显。
另外,比较稳定的是SettlePage,当然,settlePage的变化延迟太高,显然不太适合。
当然,主要原因还是Pager缺少相关的监听器。
如下效果显然是不行的
那如何解决这些问题呢?
因为Pager依然是体验性API,因此去重写有些不现实,在本篇我们做了一些优化,目前基本不再复现上述问题。
最终效果:
代码实现
我们这里是如何解决这个问题的呢?主要使用了如下手段
- 监听拖拽状态和滑动状态,在这个状态中用之前保存的selectedIndex去判断选中状态
- 拖拽结束和滑动结束,更新selectedIndex,这个时候用PageState.targetPage判断选中状态
- 拖拽前同步selectedIndex为pageState.currentPage
java
if(dragState == PAGER_STATE_DRAG_START){
selectIndex = pagerState.currentPage
}
val isSelectedItem = pagerState.targetPage == index
if (dragState == PAGER_STATE_DRAG_START || dragState == PAGER_STATE_DRAGGING || pagerState.isScrollInProgress) {
selectIndex == index
} else if (pagerState.targetPage == index) {
selectIndex = index;
true
} else {
false
}
下面是完整的代码
java
const val PAGER_STATE_DRAG_START = 0; //拖拽开始
const val PAGER_STATE_DRAGGING = 1; //拖拽中
const val PAGER_STATE_IDLE = 2; // 拖拽结束
class TabActivity : ComponentActivity() {
val tabData = getTabList()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposeTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
MainScreen()
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MainScreen() {
val pagerState = rememberPagerState(initialPage = 0) {
tabData.size
}
var dragState by remember {
mutableIntStateOf(PAGER_STATE_IDLE)
}
Column(modifier = Modifier.fillMaxSize()) {
TabContent(pagerState, modifier = Modifier
.weight(1f)
.motionEventSpy { event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN ->
dragState = PAGER_STATE_DRAG_START
MotionEvent.ACTION_MOVE ->
dragState = PAGER_STATE_DRAGGING
MotionEvent.ACTION_UP ->
dragState = PAGER_STATE_IDLE
else -> {
dragState = dragState
}
}
}
)
TabLayout(tabData, pagerState,dragState)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TabLayout(tabData: List<Pair<String, ImageVector>>, pagerState: PagerState, dragState: Int) {
val scope = rememberCoroutineScope()
var selectIndex by remember { mutableIntStateOf(0) }
/* val tabColor = listOf(
Color.Gray,
Color.Yellow,
Color.Blue,
Color.Red
)
*/
TabRow(
selectedTabIndex = pagerState.currentPage,
divider = {
Spacer(modifier = Modifier.height(0.dp))
},
indicator = { tabPositions ->
TabRowDefaults.Indicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[pagerState.currentPage]),
height = 0.dp,
color = Color.White
)
},
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
) {
tabData.forEachIndexed { index, s ->
if(dragState == PAGER_STATE_DRAG_START){
selectIndex = pagerState.currentPage
}
val isSelectedItem = pagerState.targetPage == index
if (dragState == PAGER_STATE_DRAG_START || dragState == PAGER_STATE_DRAGGING || pagerState.isScrollInProgress) {
selectIndex == index
} else if (pagerState.targetPage == index) {
selectIndex = index;
true
} else {
false
}
val tabTintColor = if (isSelectedItem) {
Red
} else {
LocalContentColor.current
}
Tab(
modifier = Modifier.drawBehind {
if(isSelectedItem) {
drawCircle( color = PurpleGrey80, radius = (size.minDimension - 8.dp.toPx())/2f)
}
},
selected = pagerState.currentPage == index,
onClick = {
scope.launch {
selectIndex = index
pagerState.animateScrollToPage(index)
}
},
icon = {
Icon(imageVector = s.second, contentDescription = null, tint = tabTintColor,
modifier = Modifier.drawWithContent {
drawContent()
} .layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width , placeable.height ) {
placeable.placeRelative(0,15)
}
}
)
},
text = {
Text(text = s.first, color = tabTintColor, fontSize = 12.sp, modifier = Modifier.scale(0.8f))
},
selectedContentColor = TabRowDefaults.containerColor
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TabContent(
pagerState: PagerState,
modifier: Modifier
) {
HorizontalPager(state = pagerState, modifier = modifier) { index ->
when (index) {
0 -> {
HomeScreen()
}
1 -> {
SearchScreen()
}
2 -> {
FavoritesScreen()
}
3 -> {
SettingsScreen()
}
}
}
}
private fun getTabList(): List<Pair<String, ImageVector>> {
return listOf(
"Home" to Icons.Default.Home,
"Search" to Icons.Default.Search,
"Favorites" to Icons.Default.Favorite,
"Settings" to Icons.Default.Settings,
)
}
当然,以上方式也有不合理的地方,比如监听PointerEvent不够精准,最好的方式还是使用MotionEventSpy去监听。
总结
以上就是本篇的内容,在本篇文章中,我们总结了声明式UI的特点,同时使用三个小demo体验了一下Compose UI开发,当然,有些地方理解不够深,瑕疵肯定是有的,本篇也会长期保持更新。
目前来时,Compose UI是趋势,但是,一些传统UI也有必要去了解。目前而言,无论是Compose UI还是Flutter UI,对于SurfaceView、TextureView、Canvas然需要依赖原生Android的。
不过,后续会不会有Compose UI方面的组件呢,目前还不好说。