Android Jetpack Compose开发体验

前言

"使用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方面的组件呢,目前还不好说。

相关推荐
EterNity_TiMe_8 分钟前
【论文复现】农作物病害分类(Web端实现)
前端·人工智能·python·机器学习·分类·数据挖掘
余生H28 分钟前
深入理解HTML页面加载解析和渲染过程(一)
前端·html·渲染
吴敬悦1 小时前
领导:按规范提交代码conventionalcommit
前端·程序员·前端工程化
ganlanA1 小时前
uniapp+vue 前端防多次点击表单,防误触多次请求方法。
前端·vue.js·uni-app
洞见不一样的自己1 小时前
android 常用方法
android
卓大胖_1 小时前
Next.js 新手容易犯的错误 _ 性能优化与安全实践(6)
前端·javascript·安全
m0_748246351 小时前
Spring Web MVC:功能端点(Functional Endpoints)
前端·spring·mvc
暗碳1 小时前
华为麦芒5(安卓6)termux记录 使用ddns-go,alist
android·linux
SomeB1oody1 小时前
【Rust自学】6.4. 简单的控制流-if let
开发语言·前端·rust
云只上1 小时前
前端项目 node_modules依赖报错解决记录
前端·npm·node.js