使用 Jetpack Compose 构建 Android 聊天应用
引言和背景
在上一篇关于 Jetpack Compose 的文章中,我们分享了初步印象并推荐了一些学习资源。这次,我们将使用 Compose 构建一个基本的聊天 UI,亲身体验一番!
Stream 现在提供Jetpack Compose Chat SDK。快来看看Compose Chat 消息教程,今天就来试试吧!
当然,你可能会好奇,既然谷歌官方已经有了Jetchat这个使用 Compose 的聊天应用示例,为什么我们还要自己开发一个 Compose 聊天应用呢?Jetchat 只是一个基本的 UI 演示,包含少量硬编码数据。而我们这里要开发的是一个真正可用的聊天应用,它连接到了一个实时后端服务。
我们的聊天 SDK 中已经包含一个UI 组件库,其中包含可直接使用、功能齐全的 Android 视图,您可以将其添加到您的应用中,此外还包含 ViewModel,只需一行代码即可将它们与业务逻辑连接起来。您可以在我们的Android 聊天教程中查看其工作原理。
这次,我们将复用之前实现中的 ViewModel(以及一个 View),并在此基础上构建基于 Compose 的 UI。但这并非理想方案,因为这些 ViewModel 的设计初衷是与它们自带的 View 配合使用。
如果 SDK 中能专门支持 Compose,那将会方便得多,也简单得多,这也是我们未来会努力的方向。不过,正如你所看到的,即使没有任何专门的支持,将 Stream Chat 与 Jetpack Compose 集成也已经相当容易了!
本文中介绍的项目已发布在 GitHub 上,欢迎克隆项目并自行体验!我们建议您为此获取自己的 Stream API 密钥,您可以通过注册免费试用版来获取。即使试用期结束后,Stream 仍然对业余项目和小公司免费开放——只需申请一个免费的 Maker 帐户即可。
项目设置
要使用 Jetpack Compose,我们将使用最新 Canary 版本的 Android Studio创建项目。其中包含一个 Compose 应用的项目模板。
项目创建完成后,第一步是将 Stream UI Components SDK 添加为依赖项,以及一些我们需要的 Jetpack Compose 依赖项。部分依赖项来自 Jitpack,因此请确保将该仓库添加到settings.gradle文件中:
dependencyResolutionManagement {
repositories {
...
maven {url "https://jitpack.io" } // Add Jitpack
}
}
然后,在模块build.gradle文件中,将该dependencies代码块替换为以下内容:
dependencies {
// Stream Chat UI Components
implementation "io.getstream:stream-chat-android-ui-components:4.8.1"
// Google / Jetpack dependencies
implementation "androidx.compose.runtime:runtime:$compose_version"
implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.3.0'
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0'
implementation "androidx.navigation:navigation-compose:1.0.0-alpha10"
implementation 'androidx.activity:activity-compose:1.3.0-alpha06'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha04'
}
如果遇到依赖关系方面的问题,请查看GitHub 项目。
接下来,我们将设置 Stream Chat SDK,为其提供 API 密钥和用户详细信息。这通常需要在您的自定义Application类中完成。为了简化操作,我们将使用与教程中相同的环境和用户。
class ChatApplication : Application() {
override fun onCreate() {
super.onCreate()
val client = ChatClient.Builder(appContext = this, apiKey = "b67pax5b2wdq").build()
val user = User().apply {
id = "tutorial-droid"
image = "https://bit.ly/2TIt8NR"
name = "Tutorial Droid"
}
ChatDomain.Builder(client, this)
.offlineEnabled()
.build()
client.connectUser(
user,
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidHV0b3JpYWwtZHJvaWQifQ.NhEr0hP9W9nwqV7ZkdShxvi02C5PR7SJE7Cs4y7kyqg"
).enqueue()
}
}
这里我们初始化了SDK的全部三个层:底层客户端、提供离线支持的域以及UI组件。更多信息请参阅文档。最后,我们使用教程中提供的硬编码的、永不过期的示例令牌来连接用户。
由于我们创建了一个自定义Application类,因此需要更新清单文件,以便ChatApplication在应用启动时使用它。我们还会进行一些设置windowSoftInputMode,MainActivity以便后续获得更好的键盘行为。
<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
<application
android:name=".ChatApplication"
...>
<activity
android:name=".MainActivity"
...
android:windowSoftInputMode="adjustResize">
...
</activity>
</application>
</manifest>
频道列表屏幕
首先,我们将创建一个屏幕,列出用户可用的频道。UI 组件库中包含一个ChannelListView 组件,可用于此目的,我们可以在此处重用其 ViewModel。
@Composable
fun ChannelListScreen(
channelListViewModel: ChannelListViewModel = viewModel( // 1
factory = ChannelListViewModelFactory()
),
) {
val state by channelListViewModel.state.observeAsState()
val channelState = state ?: return // 2
Box( // 3
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (channelState.isLoading) {
CircularProgressIndicator() // 4
} else {
LazyColumn(Modifier.fillMaxSize()) { // 5
items(channelState.channels) { channel ->
ChannelListItem(channel)
Divider()
}
}
}
}
}
让我们一步步看看这段代码是如何运行的:
- 我们使用
viewModel()Compose 提供的方法获取当前上下文的 ViewModel。这允许我们指定一个创建 ViewModel 的工厂,我们需要向其ChannelListViewModel传递一些参数。ChannelListViewModelFactory目前,我们将使用 SDK 的默认参数,这些参数会筛选出当前用户所属的频道。 - 我们将
LiveDataViewModel 中的状态视为可组合对象State,并将其复制到局部变量中,以便智能类型转换能够正确生效。此外,如果State恰好是可组合对象,我们还会跳过任何渲染操作null。 - 我们将让它
ChannelListScreen铺满整个屏幕,并将所有内容居中显示在其中。 - 如果状态正在加载,我们将显示一个基本的进度指示器。
- 否则,我们将以列表形式显示频道
LazyColumn。每个列表项负责ChannelListItem显示单个频道的信息。我们还在Divider每个列表项后添加了一个简单的 <div> 标签。
现在,让我们看看如何渲染ChannelListItem列表中的单个元素:
@Composable
fun ChannelListItem(channel: Channel) {
Row( // 1
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(channel) // 2
Column(modifier = Modifier.padding(start = 8.dp)) { // 3
Text(
text = channel.getDisplayName(LocalContext.current),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = 18.sp,
)
val lastMessageText = channel.messages.lastOrNull()?.text ?: "..."
Text(
text = lastMessageText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
- 我们将每个项目渲染为一个
Row。 - 其中,我们添加了一个
Avatar代表Channel开头的元素。 - 然后我们添加一个
Column,垂直排列两个元素Text。这两个元素将分别包含频道标题和最新消息预览。请注意,LocalContext.current我们使用 APIContext在撰写消息时获取元素,以及TextOverflow.Ellipsis用于使长消息在预览中显示良好的选项。
我们还需要实现最后一个 Composable,也就是Avatar上面提到的那个。我们的UI 组件 SDK自带一个AvatarView包含复杂渲染逻辑的组件,我们暂时不想重新实现它。我们可以使用Jetpack Compose 的互操作性功能,将我们常规的 Android 组件集成View到 Compose UI 中。
这非常简单,只需几行代码即可创建一个包装器:
@Composable
fun Avatar(channel: Channel) {
AndroidView(
factory = { context -> AvatarView(context) },
update = { view -> view.setChannelData(channel) },
modifier = Modifier.size(48.dp)
)
}
这个AndroidView函数允许我们创建一个……嗯,一个 Android 对象View,我们为其获取一个Context属性。它还提供了一种方法,让我们可以使用其参数来接入 Compose 的状态更新update,该参数会在可组合状态(在我们的例子中是对象)发生变化时执行Channel。当这种情况发生时,我们会将新的属性设置Channel到我们的对象上AvatarView。
最后,我们可以ChannelListScreen加上MainActivity:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposeChatTheme {
Surface(color = MaterialTheme.colors.background) {
ChannelListScreen()
}
}
}
}
}
构建并运行这段代码后,我们就能得到一个从 Stream 后端加载的频道列表,而且滚动效果也相当流畅LazyColumn。整个功能只有一百行代码,效果相当不错!
设置导航
继续我们的聊天应用,让我们创建一个新屏幕,用于显示频道中的消息列表。为此,我们需要设置点击监听器和导航。这里我们将使用Jetpack Compose 的导航组件。
第一步,我们将使元素ChannelListItem可点击,并添加一个onClick参数,当元素被点击时,该参数将被调用:
@Composable
fun ChannelListItem(
channel: Channel,
onClick: (channel: Channel) -> Unit, // New callback parameter
) {
Row(
modifier = Modifier
.clickable { onClick(channel) } // Add clickablity
.padding(horizontal = 8.dp, vertical = 8.dp)
.fillMaxWidth(),
...
) { ... }
}
ChannelListScreen它将接受一个参数,并根据频道的NavController,在点击项目时使用该参数导航到新的目标位置。cid
@Composable
fun ChannelListScreen(
navController: NavController, // New parameter
channelListViewModel: ChannelListViewModel = viewModel(
factory = ChannelListViewModelFactory()
),
) {
...
items(channelState.channels) { channel ->
ChannelListItem(
channel = channel,
onClick = { navController.navigate("messagelist/${channel.cid}") },
)
Divider()
}
...
}
我们将MainActivity通过将顶层可组合代码移至ChatApp可组合对象并设置其中的导航来进行更新。
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposeChatTheme {
Surface(color = MaterialTheme.colors.background) {
ChatApp()
}
}
}
}
}
@Composable
fun ChatApp() {
val navController = rememberNavController() // 1
NavHost(navController, startDestination = "channellist") {
composable("channellist") { // 2
ChannelListScreen(navController = navController)
}
composable("messagelist/{cid}") { backStackEntry -> // 3
MessageListScreen(
navController = navController,
cid = backStackEntry.arguments?.getString("cid")!!
)
}
}
}
- 我们正在为我们的应用程序创建 a
NavController和 aNavHost。 - 我们的第一个初始目标是可组合的:
ChannelListScreen。我们将导航控制器传递给它。 - 我们的第二个目的地是一个名为 `<composible>` 的新可组合对象
MessageListScreen。在这里,我们传入导航控制器和用于导航到该对象的参数。我们可以从NavBackStackEntry参数中获取该参数。
消息列表屏幕
我们的消息列表将类似于LazyColumn之前创建的频道列表。为了显示消息,UI 组件库提供了一个MessageListViewModelFactory可以创建多个不同 ViewModel 实例的组件。该组件会获取cid我们要显示的频道的 ID。
@Composable
fun MessageListScreen(
navController: NavController,
cid: String,
) {
val factory = MessageListViewModelFactory(cid)
MessageList(
navController = navController,
factory = factory,
)
}
我们将按MessageList如下方式实现:
@Composable
fun MessageList(
navController: NavController,
factory: MessageListViewModelFactory,
modifier: Modifier = Modifier,
messageListViewModel: MessageListViewModel = viewModel(factory = factory), // 1
) {
val state by messageListViewModel.state.observeAsState() // 2
val messageState = state ?: return
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
when (messageState) { // 3
is MessageListViewModel.State.Loading -> {
CircularProgressIndicator()
}
is MessageListViewModel.State.NavigateUp -> {
navController.popBackStack()
}
is MessageListViewModel.State.Result -> {
val messageItems = messageState.messageListItem.items // 4
.filterIsInstance<MessageListItem.MessageItem>()
.filter { it.message.text.isNotBlank() }
.asReversed()
LazyColumn(
modifier = Modifier.fillMaxSize(),
reverseLayout = true, // 5
) {
items(messageItems) { message ->
MessageCard(message) // 6
}
}
}
}
}
}
让我们回顾一下:
- 我们
MessageListViewModel从工厂获得一个,它会为我们获取消息,并将其公开为LiveData。 - 与之前一样,我们将
LiveDataViewModel 中的状态转换为可组合的状态State。 - 我们需要处理三种状态,它们由一个密封类定义
MessageListViewModel。第一种状态是加载状态,第二种状态会将我们推回到上一屏幕(使用导航控制器),第三种状态是结果状态,其中包含一个消息列表。 - 在结果状态下,我们将获取已接收的消息列表,并筛选出包含
MessageItem字符串的消息(排除日期分隔符等暂时不需要渲染的内容)。我们还会筛选出包含非空文本的消息——例如,有些消息可能只包含图片附件,为了简化操作,我们将跳过这些消息。最后,我们将列表反转,以匹配下一步的操作。 - 我们使用
reverseLayoutonLazyColumn从底部堆叠物品。这类似于使用stackFromEndonRecyclerView。 - 每个元素都将由一个可组合组件渲染
MessageCard。
对于单条消息,我们将使用以下布局:
@Composable
fun MessageCard(messageItem: MessageListItem.MessageItem) { // 1
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalAlignment = when { // 2
messageItem.isMine -> Alignment.End
else -> Alignment.Start
},
) {
Card(
modifier = Modifier.widthIn(max = 340.dp),
shape = cardShapeFor(messageItem), // 3
backgroundColor = when {
messageItem.isMine -> MaterialTheme.colors.primary
else -> MaterialTheme.colors.secondary
},
) {
Text(
modifier = Modifier.padding(8.dp),
text = messageItem.message.text,
color = when {
messageItem.isMine -> MaterialTheme.colors.onPrimary
else -> MaterialTheme.colors.onSecondary
},
)
}
Text( // 4
text = messageItem.message.user.name,
fontSize = 12.sp,
)
}
}
@Composable
fun cardShapeFor(message: MessageListItem.MessageItem): Shape {
val roundedCorners = RoundedCornerShape(16.dp)
return when {
message.isMine -> roundedCorners.copy(bottomEnd = CornerSize(0))
else -> roundedCorners.copy(bottomStart = CornerSize(0))
}
}
这段代码大部分都很直观,但我们还是来回顾一下其中的一些重要部分:
- 我们以 a
MessageItem作为参数。 - 根据消息是当前用户还是其他用户的消息,我们会将其对齐到屏幕的一侧。我们还会根据此信息在一些地方设置颜色。
- 我们使用
cardShapeFor辅助方法来创建包含消息的形状Card。这将创建一个圆角形状,但底部一个角除外,从而呈现出聊天气泡的外观。 - 我们在每条消息下方显示用户名,以便区分其他人发送的消息。
此时,我们可以再次构建和运行,点击频道将导航到新屏幕,显示其中的消息列表。
消息输入
在 Jetpack Compose 聊天实现的最后一个部分,我们将在消息列表屏幕上添加一个输入视图,以便我们可以发送新消息。
首先,我们将修改MessageListScreen并放置一个新的可组合组件在MessageList:
@Composable
fun MessageListScreen(
navController: NavController,
cid: String,
) {
Column(Modifier.fillMaxSize()) {
val factory = MessageListViewModelFactory(cid)
MessageList(
navController = navController,
factory = factory,
modifier = Modifier.weight(1f),
)
MessageInput(factory = factory)
}
}
修饰符会使其占据新元素上方的最大可用空间。我们还将传递给,以便它能够通过 ViewModel 访问当前打开的通道的数据weight。MessageListMessageInputfactoryMessageInput
@Composable
fun MessageInput(
factory: MessageListViewModelFactory,
messageInputViewModel: MessageInputViewModel = viewModel(factory = factory), // 1
) {
var inputValue by remember { mutableStateOf("") } // 2
fun sendMessage() { // 3
messageInputViewModel.sendMessage(inputValue)
inputValue = ""
}
Row {
TextField( // 4
modifier = Modifier.weight(1f),
value = inputValue,
onValueChange = { inputValue = it },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions { sendMessage() },
)
Button( // 5
modifier = Modifier.height(56.dp),
onClick = { sendMessage() },
enabled = inputValue.isNotBlank(),
) {
Icon( // 6
imageVector = Icons.Default.Send,
contentDescription = stringResource(R.string.cd_button_send)
)
}
}
}
- 这次,我们将使用一个
MessageInputViewModel通常属于UI 组件库中提供的MessageInputView 的组件,它用于处理输入操作。 - 我们创建一个可组合的状态来保存当前的输入值。
- 这个本地辅助函数会调用 ViewModel 并将消息发送到服务器。然后,它通过重置状态来清除输入字段。
- 我们使用一个组件
TextField来捕获用户输入。该组件会显示当前输入inputValue,并根据键盘输入对其进行修改。我们还设置了输入法编辑器选项,以便我们的软件键盘显示一个“发送”按钮,并通过调用 `getSend()` 来处理该按钮的点击事件sendMessage。 - 我们还添加了
Button一个用户可以点击发送消息的按钮。该按钮会根据当前输入值动态启用/禁用。 - 按钮将显示 Material 默认图标集中的一个图标。为了确保可访问性,我们还为发送图标添加了内容描述字符串。我们使用 `<resource>` 从常规 Android 资源中获取此字符串
stringResource,这样就可以像往常一样进行本地化。请确保在您的项目中创建此资源。
让我们最后一次构建并运行,现在我们有了一个可以正常运行的聊天应用!我们可以浏览频道、打开频道、阅读消息和发送新消息。
结论
整个实现方案,包括用户界面、逻辑以及与真实服务器的连接,总共大约只有 250 行代码。其中几乎全部是使用 Jetpack Compose 编写的用户界面代码,与 Stream Chat 的 ViewModel 和 Views 集成的部分只占很小一部分。
关注我们的推特账号@getstream_io和作者@zsmb13,获取更多类似内容。如果您喜欢这篇教程,请在推特上告诉我们!
提醒一下,您可以在 GitHub 上查看完整项目并进行体验。我们建议您扩展此项目以深入了解 Compose——一个不错的初始目标是添加登录界面,以便您可以在不同的设备上使用不同的用户。
要立即体验,请注册Stream Chat 的免费试用版。如果您是将其用于副业项目或小型企业,即使试用期结束后,您也可以使用我们的 SDK 和免费的 Maker 帐户!
以下是一些可供您继续探索的实用链接:
- GitHub 上的 Stream Chat Android SDK,包括展示众多功能的UI 组件示例应用程序,以及可供您借鉴的登录实现。
- 我们常规的非 Compose Android 教程
- Stream Chat Android 文档
- 想了解更多关于 Compose 的信息、一些初步印象以及推荐的学习资源?
原文发表于getstream.io/blog。
文章来源:https://dev.to/zsmb13/build-an-android-chat-app-with-jetpack-compose-29k8





