链接:https://juejin.cn/post/7356437111601758218 本文由作者授权发布
“使用JetPack Compose 更快地构建更好的应用程序” Jetpack Compose 是 Android 推荐的用于构建本机 UI 的现代工具包。它简化并加速了 Android 上的 UI 开发。使用更少的代码、强大的工具和直观的 Kotlin API 快速让您的应用程序栩栩如生。
作为Android开发者,xml布局和Compose布局大家应该很熟悉,而Compose作为Android平台上第二款支持声明式UI的框架,第一款是Flutter框架了。
声明式UI有哪些特点,作为开发者应该如何学习呢?
随着Compose UI的日渐成熟,作为Android开发者,很多UI方面的技术又得重新再来,即便是成熟的Android开发者,也得重新去理解一些设计思想,因此,某些方面可以说,在Compose UI这里大家的起跑线是一样的。
作为一款UI框架,无论是xml和compose ui,其实有特定的学习路线,我们要围绕下面几个点,就能快速入门Compose UI
但是,如何与业务关联,作为声明式UI,天然的优势就是双向绑定了,主要从下面几个点去着手。
声明式UI有哪些特点呢?
比较令人疑惑的是,迄今为止似乎没人知道为啥叫Jetpack Compose,特别是Jetpack该怎么理解呢?
实际上Google在文字创造领域一直很处于前沿,比如“Google”本身就没有什么意义,也不是单词。Android的每个版本都会有名称,但即便这样你还得翻阅android.os.Build类去查阅这些代号,平时你也不会给别人说你用的是Hello Kitty版本。包括Android上的Material UI,依稀记得以前称之为Material Language,不知道后来为什么变成了Material UI了,显然,我觉得「Jetpack Compose」这个也有最终有可能完全变成「Compose UI」这种叫法。另外,本篇还会有个叫“事件间谍的”方法MotionEventSpy,话说写UI的开发者各个都是特工么?
扯得有点远,不管叫是什么,反正都要学习。
那么,是不是声明式UI完美无缺呢?
也不是,在目前来说,Compose UI一些组件如Pager还是有些不成熟的,另外性能方面也有些不足,这也就呼应了本篇开头的jetpack compose官网那句话
“使用JetPack Compose 更快地构建更好的应用程序”
其实,开发者显然期待的是
“使用JetPack Compose 更快地构建更好的「更快的」应用程序”
在软件开发中,【性能快】可以避免很多问题。
相比Flutter,Compose在一些方面更加先进,得益于Kotlin编译器的作用,作为一门新式语言,Kotlin有大量的关键词、注解、语法糖来快速转换和生成代码,compose 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,后期会不会衍生到非web领域,应该保持期待。目前wasm这种语言旨在统一语法树来实现更底层的兼容。
总之目前来说每种路线各有优势,kotlin生成dart 字节码理论上也是可以的,反过来,如果使用graal vm,dart也可以直接在android上跑。而wasm如果能在web中统一,那么参考webrtc的路线,在操作系统上统一也是有可能的 。
布局方面,Flutter的Widget是显示的节点组装,而在Compose这里变成了隐式的节点组装,对于代码可读性而言,flutter相对友好一些,毕竟Dart更像Java的方式,而Kotlin由于语法糖较多,因此存在很多隐式调用的逻辑,不那么容易看到一些源码。使用方面,Compose更加简洁一些,不用类似Flutter的那种child,而且是纯函数实现。
说到状态管理,其实这点要结合语言的特性,Compose推荐是各种类似闭包的remember,而Flutter比较关注的是集中式管理。
当然,这也和语言的特性有关,应该尽可能从语言方面去思考,比如redux在flutter上的使用就很失败,究其原因主要是没有利用好stream,同时让flutter的开发效率变得更低,索性后来有了GetX、Provider。
实际上,Compose UI也是可以利用ViewMode。l实现集中式管理的 ,开发中应该尽可能闭包式管理。目前来说,关于状态问题还涉及到状态提升,但一些文章推荐的竟然是callback机制,这种无异于”地狱回调”,我个人观点应该尽可能去避免。
在灵活性方面,Kotlin其实要比Dart灵活很多,在UI层面,Compose做法非常新颖,比如有状态函数和无状态函数,另外还有各种remember函数,但这方面会不会成为kotlin的包袱呢。可扩展性方面,两者差距不大,但是在组件自身上,kotlin其实灵活度更高,主要体现在Modifier的各种draw函数上,如果Modifier不支持的属性,通过Modifer就能实现转换,甚至还能干预到最终样式。为什么能这样呢,因为任何组件都需要绘制的,Modifier提供的类似Hook的机制,更加强大。
目前来说,相比Flutter而言,Compose的一些组件性能很不理想,这点在模拟器中表现更加明显,Compose显然还需要提升性能,不然低端机型甚至iOT设备上就会和Compose相见无缘。
Compose UI目的旨在兼容更多平台,从底层嫁接 UI Node节点,如AndroidComposeView的实现,这种相比flutter的引擎,显然要做更多的底层适配。可想而知,未来面临的问题其实不少。
Compose UI中的Text对富文本的支持其实是弱化了的,当然可行的方法是使用Flow布局去实现,但另一个问题是,html解析如果沿用android 的span标记,就无法适应其他平台,因此这是一种妥协了。当然,androider们肯定也不乐意,你跨你的平台,何必弱化android的功能?
这方面,flutter做的就是很负责任,自行实现了RichText。
无论Flutter 还是Kotlin,他们的起点都是多点触控,这相当于比通常的android View处理层次更高一些,不过还是遵循dispatchInputEvent和finishInputEvent那套逻辑。两者的MotionEvent都是支持多点触控的,使用起来也都很简单,差异不大。不过,也有些差异的地方,比如flutter有让人比较难理解的InkWell,而Compose则有MotionEventSpy(事件间谍)。
在compose UI中,everything is Node,如Layout Node、input Node和modifier Node,这就造成了一个问题,在特殊情况下,很难追踪事件被哪个compose消费了。
相比android view事件可以按深度优先搜索查找mFirstTouchTarget进行追踪,但compose UI这方面目前还没有相关实现。
在Android 平台,焦点可以通过监听Global Focus进行追踪,但Compose UI似乎没有相关方法,当然也有可能我还没看到。
如果做TV开发,焦点无法追踪的话,那么开发很难去处理一些焦点陷阱问题,那么用compose ui开发TV app可能需要谨慎一点。
Flutter和Compose 都能接入原生组件,同时都支持通过Canvas绘制,但前面说过,Compose UI的任何支持Modifier组件理论上都可以绘制。至于布局自定义方面,两者也支持自定义布局,但Compose的Modifier 更加灵活。
由于compose ui和flutter的渲染方式有一定的差异,flutter 在渲染引擎方面是统一的,但compose ui就比较依赖平台了,这点似乎和react native有点相似了,因此,可想而知,compose ui在展示一致性方面要比flutter考虑的要多一些。
在开发框架选择这方面,就目前而言,有一条不变的规律:
开发效率要先于运行效率
这个很容易理解,kotlin开发效率比C++高,但运行效率kotlin却无法打败C++。
开发中要使用flutter还是Compose,其实这个一定要看业务,作为开发者,要做到两件事。
为了解决很小的问题,引入一个很大的框架是不是很合适呢?再比如说,纯业务交互app,完全不涉及底层,你用哪种不行呢 ?
本篇说了很多总结性的内容,下面本篇会通过四个案例,来体验一下Compose的魅力。
手电筒效果在之前文章中有过设计,不过,本篇的主要代码还是官网的demo基础上改造的,相比而言,使用Modifier的draw函数,灵活性非常高。
在这个案例中,我们利用MotionEventSpy修复了官网的按下时触点位置不准确或者偏倚太大的问题,另外,我们会看到remember托管的变量隐式转换。
源码如下
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
}
在上面的代码中,remmeber这里起到了闭包的作用,还有就是我们看不到的还有状态订阅的相关逻辑,这种理论上是赋值操作做了转换,应该是在生成字节码时做了处理,这部分我没有细看,后续有时间分析。
第二点我们看到,Brush的作用,其类似android Paint的Shader,不过上面的代码使用Brush的会频繁创建对象,这点没有android传统View的Shader#setLocalMatrix好,不知道后续官方会不会优化。
下面是一个简单的位置偏移动画,也是来自JetPack Compose官方教程中的
在这个动画中,还有一点需要注意的是,偏移方式是通过Offset方式,类似Android中的View修改Left、Top、Right、Bottom,在Android View中此类动画性能一般,在Compose中理论上也不会太理想,实现偏移动画这方面应该还有其他方式,比如matrix变换方式,相信compose ui不会太差。
不过,这不是重点,重点是我们可以看到,在Modifier中直接修改Compose UI的相对位置。
我们知道,在Compose中是有padding的,但是没有margin,一些博客中建议用Border代替Margin,理论上也行,但是Border部分的点击事件如何屏蔽呢?其实使用layout方式可能更好。
另外有同学说,使用padding和background可以实现padding和margin效果,只是顺序不一样,确实也是可以的。
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是非常经典且流行程度很高的布局,我本篇使用的是Foundation 1.5的版本,Pager组件还是体验性API,也就是不稳定的版本。
但在滑动过程中PageState有很多不稳定的Bug。比如currentPage不稳定,但这个可以理解,从4->1 中间有4->2->3->1,毕竟要驱动indicator指示器,但是targetPage也不稳定而且还抖动,这就有点说不过去了 。
另外,如果在无法滑动时继续滑动,还可能出现targetPage向相反方向,这个问题应该还是比较普遍的,我看一些博客使用的是snapshotFlow去防抖监听,但是这种也是有问题的,在线程优先级高的时候,减少了问题发生的概率,但是如果线程性能慢或者优先级低一些的话,出现频率就会很明显。
在PageState中,最稳定的是SettlePage,当时settlePage的变化状态延迟太高,显然不太适合这种切换。
其实,这里的问题,主要原因还是Pager缺少相关的监听器。
如下效果显然是不行的
那如何解决这些问题呢?
因为Pager依然是体验性API,因此去重写有些不现实,在本篇我们做了一些优化,目前基本不再复现上述问题。
最终效果:
代码实现
我们这里是如何解决这个问题的呢?主要使用了如下手段
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
}
下面是完整的代码
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,
)
}
当然,以上方式也有不合理的地方,比如PagerState状态与selectedIndex之间还需要同步,最好的方式还是从Pager内部实现兼容。
借助Modifier扩展来实现涂鸦效果,在这部分我们要了解下Modifier的扩展,在这部分我们可以看到「this then Modifier」这种比较古怪的写法。
fun Modifier.absoluteOffset(
offset: Density.() -> IntOffset
) = this then OffsetPxElement(
offset = offset,
rtlAware = false,
inspectorInfo = {
name = "absoluteOffset"
properties["offset"] = offset
}
)
在kotlin中then并不是关键字,官方的解释是连接两个Modifier,其本质是个infix函数,不过下面让人难以理解的是(other === Modifier)这种比较,后一个Modifier代表什么呢 ?其实这是Modifier中默认的伴生对象,因此,这里返回this的原因是因为使用伴生对象没有意义。
infix fun then(other: Modifier): Modifier =
if (other === Modifier) this else CombinedModifier(this, other)
那么,扩展Modifier我们也应该遵循这种规则,下面是一个很好案例。
这个效果是来自《[compose] 仿刮刮乐效果》这篇文章,这个案例很经典。
class GuaguaCardActivity : ComponentActivity() {
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
) {
ScrapeLayerPage()
}
}
}
}
}
@Composable
fun ScrapeLayerPage(){
var linePath by remember {
mutableStateOf(Offset.Zero)
}
val path by remember {
mutableStateOf(Path())
}
Column(modifier = Modifier
.fillMaxWidth()
.pointerInput("dragging") {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
when (event.type) {
//按住时,更新起始点
Press -> {
path.moveTo(
event.changes.first().position.x,
event.changes.first().position.y
)
}
//移动时,更新起始点 移动时,记录路径path
Move -> {
linePath = event.changes.first().position
}
}
}
}
}
.scrapeLayer(path, linePath)
) {
Image(
modifier = Modifier.fillMaxSize(),
painter = painterResource(id = R.mipmap.img_pic),
contentDescription = ""
)
}
}
fun Modifier.scrapeLayer(startPath: Path, moveOffset: Offset) =
this.then(ScrapeLayer(startPath, moveOffset))
class ScrapeLayer(private val strokePath: Path, private val moveOffset: Offset) : DrawModifier {
private val pathPaint = Paint().apply {
alpha = 0f
style = PaintingStyle.Stroke
strokeWidth = 70f
blendMode = BlendMode.Clear
strokeJoin = StrokeJoin.Round
strokeCap = StrokeCap.Round
}
private val layerPaint = Paint().apply {
color = Color.Gray
}
override fun ContentDrawScope.draw() {
drawContent()
drawIntoCanvas {
val rect = Rect(0f, 0f, size.width, size.height)
it.saveLayer(rect, layerPaint)
//从当前画布,裁切一个新的图层
it.drawRect(rect, layerPaint)
strokePath.lineTo(moveOffset.x, moveOffset.y)
it.drawPath(strokePath, pathPaint)
it.restore()
}
}
}
效果
另外,在这个案例中,我们还可以看到「悬挂函数awaitPointerEvent」获取事件的方式,非常有学习的必要,当然这里有不好理解的是while(true){}中调用悬挂函数,还能跳出循环,这里主要是借助了协程取消时抛出CancellationException异常,从而能够终止while循环,可见设计非常巧妙。
实际上,整个过程体验还是不错的,但是对于remember相关的隐式和闭包调用,理论上封堵住了开发者对Compose状态优化一些手段。关于这点,有可能是受到了Kotlin/Javascript的影响,因为在Javascript中,简单的赋值操作就能触发相关的状态,而且还是隐式的。这显然是两面性的东西,但也是值得警惕的事情,为什么这样说呢,因为如果你想避免状态的触发,或者想批量触发,显然要付出更多的成本。
以上就是本篇的内容,在本篇文章中,我们总结了声明式UI的特点,同时使用四个小demo体验了一下Compose UI开发,当然,有些地方理解不够深,瑕疵肯定是有的,本篇也会长期保持更新。
目前来时,Compose UI是趋势,但是,一些传统UI也有必要去了解。目前而言,无论是Compose UI还是Flutter UI,对于SurfaceView、TextureView、Canvas然需要依赖原生Android的。
不过,后续会不会有Compose UI方面的组件呢,目前还不好说。
附: Github源码:https://github.com/soloong/ComposeLearning