查看原文
其他

用Jetpack Compose写一个玩安卓App

Zhujiang 郭霖 2022-12-14


/   今日科技快讯   /

近日,即将离任的日本松下公司首席执行官津贺一宏表示,该公司计划通过生产其他品牌电动汽车通用的电池,来减少对特斯拉的严重依赖。特斯拉已经开始自主研发电池,并将采购合作伙伴扩大到韩国的LG化学和中国的宁德时代,以支持其电动汽车销量的增长。

/   作者简介   /

Google的Jetpack Compose挑战赛还在如火如荼地进行当中,因此也一直有小伙伴不断地投稿Compose的文章,那么我们可以趁这个机会来好好学习一下Compose的用法。

本篇文章来自Zhujiang同学投稿,分享了他参加完Compose官方比赛之后对以往自己的应用使用Compose重写的过程,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章!

Zhujiang的博客地址:
https://juejin.cn/user/3913917127985240/posts

/   前言   /

本文由一场比赛引起,先看看比赛要求。


是不是很简单,只需要两个页面。


嗯。。。。差不多就是这样我就直接提交了,不过得不得奖无所谓,开心就好。

Compose 和 Flutter


在写完这个小Demo之后,有一个感觉,Compose和Flutter他们两个。。。。不能说有点相似,只能说完全一样!

Compose完全打破了我之前对安卓的看法。。和Flutter简直是一个模子里刻出来的,连命名都有些相似,虽然Google官方说不会放弃Java,但是看看协程和 Compose,真的想说一句:我信你个鬼,你个糟老头子坏的很!

虽然二者很像,但是发力的方向是完全不同的:

Flutter 大家都知道,是个 跨平台 的 UI 框架,注意,只是 UI 框架!各种复杂实现全都是安卓和苹果原生写的,可以一套代码多处使用,特别是现在 Flutter 2.0 的发布,更是不得了,支持安卓、苹果、windows、mac、网页,不得不说实在是太强了。

那 Compose 为什么会出现?又或者说它有什么用呢?

Compose 为什么会出现


这一小节的内容很明确,写 Compose 为什么会出现。这个问题其实写安卓的都比较清楚,有的就算不清楚也可以猜出二三。

以前咱们编写安卓程序的时候页面都写在 res -> layout 文件夹下,以 xml 的形式展现,这样的好处显而易见,将逻辑代码和页面彻底分开,确实分开了,但是使用的时候又需要去 findViewById,xml 布局加载的时候又需要耗费大量时间,加载完成之后还需要通过反射来获取 View,又是一大耗时操作。。

记得之前有个大神写过一篇文章,里面通过直接 new 布局和 xml 方式写布局进行比较,速度甚至相差几十倍,然后各种各样的优化就出来了,好像有个团队甚至自己编写了一整套布局,完全没有使用 xml,也是神人了!

写到这里应该都知道 Compose 为什么会出现了吧!

Compose 有什么


Compose 还有一个重要的特点——声明式。

声明式?这是什么东西?怎么说呢,就是不以命令方式改变前端视图的情况下呈现应用界面,从而使编写和维护应用界面变得更加容易。

不解释还好,一解释更加懵逼。。。简单来说就是通过对数据的改变而改变布局,不用以前 findViewById 那样遍历树,减少出错的可能性,而且软件维护复杂性会随着需要更新的视图数量而增长。不行不行,我自己都有点懵了,给大家举个例子吧!

其实咱们所熟知的 MVVM 其实就是数据驱动布局改变的,为什么这样说呢?你想一下,你的 ViewModel 中是不是通常会定义一个 LiveData,然后在你的 Activity 或者 Fragment 中进行 observe,然后将你的 UI 操作放到这里,当数据改变的时候相应地去修改你的 UI,这样说的话是不是好理解一些呢?

/   准备工作   /

我个人的一个小习惯,学习什么新东西的时候就会写个 Demo ,之前我写过一个 MVVM 版的玩安卓,而且还为这个项目写过一个系列的文章,感兴趣的可以去我的文章列表看看。

这次写完官方比赛的小 Demo 之后觉得 Compose 挺好玩,并且好多大佬都说 Compose 是未来的趋势,于是就想着把那个 MVVM 版的玩安卓改用 Compose 实现一下试试。

先来看看成品



看着是不是也还可以?那就开始着手编写吧!

准备工作


由于之前已经编写过 MVVM 版本的玩安卓了,所以说很多东西咱们就可以直接进行使用了,比如说一下图片资源,又比如说数据、网络请求等等都是现成了,咱们要做的只是将以前的 xml 布局改成 Compose 即可。

听着是不是很简单?但是写的时候有点懵,这还是我之前写过 Flutter 的情况下,如果大家没有写过 Flutter 或者 SwiftUI 的话看起来可能会更懵,因为里面好多东西都颠覆了我对安卓的看法。。。

为了区分和之前 MVVM 版本的区别,我把这次的 Compose 的版本分支改为了 main 分支,大家下载代码的时候切换下分支就可以了,或者直接下载 main 分支的代码也可以。

引入依赖


// `Compose`
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling:$compose_version"
implementation "androidx.activity:activity-compose:1.3.0-alpha03"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha02"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$compose_version"
implementation "androidx.compose.foundation:foundation:$compose_version"
implementation "androidx.compose.foundation:foundation-layout:$compose_version"
implementation "androidx.compose.material:material-icons-extended:$compose_version"

androidTestImplementation "androidx.compose.ui:ui-test:$compose_version"
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"

// navigation
implementation "androidx.navigation:navigation-compose:1.0.0-alpha08"

what?不是一个 Compose 库吗?干嘛引入这么多?我之前也是这么想的,但是在用的时候一个个又加进去的。。。如果不知道每一个包是什么意思的话可以去官方文档中查看下,不过光看依赖名称基本就知道是什么意思了。。。

如果你也想像我一样在以前的项目中使用 Compose,那么下面的这一步千万别忘了,我就是忘了添加下面这一步找了整整一天的错

android {
    …………
    buildFeatures {
        `Compose` true
        viewBinding true
    }

    `Compose`Options {
        kotlinCompilerExtensionVersion compose_version
        kotlinCompilerVersion kotlin_version
    }
}

就是上面的,一定别忘了进行配置,不然找错误能找死。。。给我提示的是 Kotlin 内部 JVM 错误,搞得我我都准备给 Kotlin 提 Bug 了。。。

大家可能看到上面的依赖中有 navigation,看名字就知道是专门为 Compose 写的,这也是 Compose 跳转的重要工具,也许有更好的,只是我没有发现吧。

上面也不止一次提到 Compose 颠覆了我之前对安卓的看法,之前的我认为安卓就是一堆 Activity 加上 Fragment ,但是写了 Compose 之后我发现并不是这样的,好多官方的 Demo 只有一个 Activity。

看得我有点懵,但是后来想了想就明白了,还是类似 Flutter ,在 Flutter 中不也是一个 Activity 嘛,每一个页面也都是一个 Widget!跳转也不是之前的 Intent ,而是路由,。现在的 Compose 也是一样,只不过 Widget 改为了 Composable,路由改为了 navigation。

今天就写一下首页框架吧,就是一个底部导航栏加上四个页面,实现点击进行切换。

新建 Activity


先来新建一个 Activity 吧,这个项目之后就用这一个 Activity,看下 AndroidManifest:

<activity
    android:name=".`Compose`.NewMainActivity"
    android:theme="@style/AppTheme.NoActionBars">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

这没什么说的,就是把之前的首页改为了新的首页,其它的 Activity 都已经用不到了。

class NewMainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            PlayTheme {
                Home()
            }
        }
    }

}

这个 Activity 很简单,大家发现了没有,咱们熟悉的 setContentView 方法没有调用,取而代之的是 setContent 方法,不过不重要,这是 Compose 的固定写法而已,当然也可以将 Compose 写到 xml 中然后通过 findViewById 来找到再进行操作,还可以直接 new 出一个 Compose 来进行操作,这里咱们就选择 setContent 这种方法。

看下  setContent  这个方法吧:

public fun ComponentActivity.setContent(
    parent: CompositionContext? = null,
    content: @Composable () -> Unit
) {
    val existingComposeView = window.decorView
        .findViewById<ViewGroup>(android.R.id.content)
        .getChildAt(0) as? ComposeView

    if (existingComposeView != null) with(existingComposeView) {
        setParentCompositionContext(parent)
        setContent(content)
    } else ComposeView(this).apply {
        // Set content and parent **before** setContentView
        // to have ComposeView create the composition on attach
        setParentCompositionContext(parent)
        setContent(content)
        setContentView(this, DefaultActivityContentLayoutParams)
    }
}

明白了吧?这是 ComponentActivity 的一个扩展方法,里面其实还会执行 setContentView 方法的,并没有什么神器的魔法。。

创建 Compose


虽然这里选择了 setContent 这种方法,但是还是说下别的情况下怎么使用吧。

可以将 ComposeView 放在 XML 布局中,就像放置其他任何 View 一样:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/hello_world"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Hello Android!" />

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

上面的布局很简单,大家注意看,咱们把 Compose 直接写在了 xml 中,这样也是可以进行使用的,怎么使用呢?

class ExampleFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        // Inflate the layout for this fragment
        return inflater.inflate(
            R.layout.fragment_example, container, false
        ).apply {
            findViewById<ComposeView>(R.id.compose_view).setContent {
                // In Compose world
                MaterialTheme {
                    Text("Hello Compose!")
                }
            }
        }
    }
}

是不是很简单,当然也可以直接 new 出一个 Compose 来进行操作:

class ExampleFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            setContent {
                MaterialTheme {
                    // In Compose world
                    Text("Hello Compose!")
                }
            }
        }
    }
}

好的,这样其实已经满足大部分的需求了,不过还有一种情况:如果同一布局中存在多个 ComposeView 元素,每个元素必须具有唯一的 ID 才能使 savedInstanceState 发挥作用:

class ExampleFragment : Fragment() {

  override fun onCreateView(...): View = LinearLayout(...).apply {
      addView(ComposeView(...).apply {
        id = R.id.compose_view_x
        ...
      })
      addView(TextView(...))
      addView(ComposeView(...).apply {
        id = R.id.compose_view_y
        ...
      })
    }
  }
}

上面的代码也不难,里面需要注意一点,ComposeView ID 需要在 res/values/ids.xml 文件中进行定义:

<resources>
    <item name="compose_view_x" type="id" />
    <item name="compose_view_y" type="id" />
</resources>

PlayTheme


上面本来想直接写主题来着,但是想了想还是说清楚一点吧,要不使用不同场景的就不知道该如何使用了,咱们来接着看刚才定义的 Activity。

setContent 中包裹了一层 PlayTheme,顾名思义,这是一个自定义的主题,这块也是颠覆我的一个地方。在我之前对安卓的认知中,主题一般存放在 values 中的 styles 文件中,现在 Compose 中已经不再使用 xml 中的主题了,取而代之的是 Compose 自己的一套主题系统。在这里我也吃过一次亏:死活修改不了颜色。

看来 Compose 对 xml 是深恶痛绝啊,一个 xml 都不想使用,包括 color。来看下 PlayTheme 的定义:

@Composable
fun PlayTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colors = if (darkTheme) {
        PlayThemeDark
    } else {
        PlayThemeLight
    }
    MaterialTheme(
        colors = colors,
        typography = typography,
        content = content
    )
}

private val PlayThemeLight = lightColors(
    primary = blue,
    onPrimary = Color.White,
    primaryVariant = blue,
    secondary = blue
)

private val PlayThemeDark = darkColors(
    primary = blueDark,
    onPrimary = Color.White,
    secondary = blueDark,
    surface = blueDark
)

定义很简单,但是根据上面描述的内容发现了一些什么没有,连主题都是 Composable ,真的像 Flutter 中的 Widget 。再来看下里面的颜色值:

val blue = Color(0xFF2772F3)
val blueDark = Color(0xFF0B182E)

val Purple300 = Color(0xFFCD52FC)
val Purple700 = Color(0xFF8100EF)

/   画页面   /

准备工作做得差不多了,来开始画页面吧!

先想想咱们最终需要做的样子,忘记的可以滑到上面再看看。

其实很简单,今天咱们只是初探嘛!只需要画出下面的底部导航栏和上面几个空页面就行了!

说干就干!先来创建一个新的 Composable:

@Composable
fun Home() {

}

很简单,一个方法加上 @Composable 的注解就是一个新的 Composable 了,咱们需要在这里画咱们的首页了。

底部导航栏


查了一下官方文档, Compose 中和 Flutter 一样有现成底部导航栏,完全够咱们使用了:

@Composable
fun Home() {
    ComposeDemoTheme {
        val (selectedTab, setSelectedTab) = remember { mutableStateOf(CourseTabs.HOME_PAGE) }
        val tabs = CourseTabs.values()
        Scaffold(
            backgroundColor = MaterialTheme.colors.primarySurface,
            bottomBar = {
                BottomNavigation(
                    Modifier.navigationBarsHeight(additional = 56.dp)
                ) {
                    tabs.forEach { tab ->
                        BottomNavigationItem(
                            icon = { Icon(painterResource(tab.icon), contentDescription = null) },
                            label = { Text(stringResource(tab.title).toUpperCase()) },
                            selected = tab == selectedTab,
                            onClick = { setSelectedTab(tab) },
                            alwaysShowLabel = false,
                            selectedContentColor = MaterialTheme.colors.secondary,
                            unselectedContentColor = LocalContentColor.current,
                            modifier = Modifier.navigationBarsPadding()
                        )
                    }
                }
            }
        ) { innerPadding ->
            val modifier = Modifier.padding(innerPadding)
            when (selectedTab) {
                CourseTabs.HOME_PAGE -> One(modifier)
                CourseTabs.PROJECT -> Two(modifier)
                CourseTabs.OFFICIAL_ACCOUNT -> Three(modifier)
                CourseTabs.MINE -> Four(modifier)
            }
        }
    }
}

上面代码有点长,但是意思很简单,稍微给大家说一下吧,Scaffold 在 Flutter 中也有,意思也是差不多的,来看一下 Scaffold 的源码吧:

@Composable
fun Scaffold(
    modifier: Modifier = Modifier,
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    topBar: @Composable () -> Unit = {},
    bottomBar: @Composable () -> Unit = {},
    snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
    floatingActionButton: @Composable () -> Unit = {},
    floatingActionButtonPosition: FabPosition = FabPosition.End,
    isFloatingActionButtonDocked: Boolean = false,
    drawerContent: @Composable (ColumnScope.() -> Unit)? = null,
    drawerGesturesEnabled: Boolean = true,
    drawerShape: Shape = MaterialTheme.shapes.large,
    drawerElevation: Dp = DrawerDefaults.Elevation,
    drawerBackgroundColor: Color = MaterialTheme.colors.surface,
    drawerContentColor: Color = contentColorFor(drawerBackgroundColor),
    drawerScrimColor: Color = DrawerDefaults.scrimColor,
    backgroundColor: Color = MaterialTheme.colors.background,
    contentColor: Color = contentColorFor(backgroundColor),
    content: @Composable (PaddingValues) -> Unit
)

它就是一个脚手架,官方是这样进行描述的:

Material 支持的最高级别的可组合项是 Scaffold。Scaffold 可让您实现具有基本 Material Design 布局结构的界面。Scaffold 可以为最常见的顶级 Material 组件(如 TopAppBar、BottomAppBar、FloatingActionButton 和 Drawer)提供插槽。通过使用 Scaffold,很容易确保这些组件得到适当放置且正确地协同工作。

意思很明确,如果不是要求自定义度特别高的页面,使用 Scaffold 就完全能满足需求了,这里咱们使用的就是。

上面代码中的 CourseTabs 还没有写,它是一个枚举类,用来表示首页的几个页面的:

enum class CourseTabs(
    @StringRes val title: Int,
    @DrawableRes val icon: Int
) {
    HOME_PAGE(R.string.home_page, R.drawable.ic_nav_news_normal),
    PROJECT(R.string.project, R.drawable.ic_nav_tweet_normal),
    OFFICIAL_ACCOUNT(R.string.official_account, R.drawable.ic_nav_discover_normal),
    MINE(R.string.mine, R.drawable.ic_nav_my_normal)
}

这里其实有的地方大家还是看不太懂的,比如上面的 remember 是个什么东西?

管理状态


上面所说的的 remember 其实是用来管理 Compose 的状态的,大家就先记着 remember 可以记录咱们点击的按钮数据,从而驱使页面发生改变吧。

mutableStateOf(CourseTabs.HOME_PAGE)  其实是 MutableState<CourseTabs> 

[mutableStateOf]:
https://developer.android.google.cn/reference/kotlin/androidx/compose/runtime/package-summary#mutableStateOf(androidx.compose.runtime.mutableStateOf.T

androidx.compose.runtime.SnapshotMutationPolicy)) 会创建可观察的 MutableState, MutableState是与 Compose 运行时集成的可观察类型。

这块一时半会说不清,需要后面的文章慢慢来和大家讲。

添加页面


上面一共创建了四个字页面:one、two、three、four,四个页面非常简单:

@Composable
fun One(modifier: Modifier) {
    Text(modifier = modifier
        .fillMaxSize()
        .padding(top = 100.dp), text = "One", color = Teal200)
}

@Composable
fun Two(modifier: Modifier) {
    Text(modifier = modifier
        .fillMaxSize()
        .padding(top = 100.dp), text = "Two", color = Teal200)
}

@Composable
fun Three(modifier: Modifier) {
    Text(modifier = modifier
        .fillMaxSize()
        .padding(top = 100.dp), text = "Three", color = Teal200)
}

@Composable
fun Four(modifier: Modifier) {
    Text(modifier = modifier
        .fillMaxSize()
        .padding(top = 100.dp), text = "Four", color = Teal200)
}

ok,这就差不多了。

/   总结   /

本篇文章就先写到这里吧,本篇简单介绍了下 Compose,并编写了一个简单的页面,之后会将整个应用全部使用 Compose 编写完成的。

最后再放一下 Github 地址吧,别忘了切换 main 分支!

Github 地址:
https://github.com/zhujiang521/PlayAndroid

推荐阅读:
我的新书,《第一行代码 第3版》已出版!
我参加了Jetpack Compose开发挑战赛
Jetpack Compose第二周挑战赛,做一个倒计时器

欢迎关注我的公众号
学习技术或投稿


长按上图,识别图中二维码即可关注

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存